chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
import Anthropic from '@anthropic-ai/sdk'
import { createLogger } from '@sim/logger'
import type { StreamingExecution } from '@/executor/types'
import { executeAnthropicProviderRequest } from '@/providers/anthropic/core'
import { getCachedProviderClient } from '@/providers/client-cache'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
const logger = createLogger('AnthropicProvider')
export const anthropicProvider: ProviderConfig = {
id: 'anthropic',
name: 'Anthropic',
description: "Anthropic's Claude models",
version: '1.0.0',
models: getProviderModels('anthropic'),
defaultModel: getProviderDefaultModel('anthropic'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
return executeAnthropicProviderRequest(request, {
providerId: 'anthropic',
providerLabel: 'Anthropic',
createClient: (apiKey, useNativeStructuredOutputs) => {
const cacheKey = `anthropic::${apiKey}::${useNativeStructuredOutputs ? 'beta' : 'default'}`
return getCachedProviderClient(
cacheKey,
() =>
new Anthropic({
apiKey,
defaultHeaders: useNativeStructuredOutputs
? { 'anthropic-beta': 'structured-outputs-2025-11-13' }
: undefined,
})
)
},
logger,
})
},
}
+85
View File
@@ -0,0 +1,85 @@
import type {
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStreamEvent,
Usage,
} from '@anthropic-ai/sdk/resources'
import { createLogger } from '@sim/logger'
import { randomFloat } from '@sim/utils/random'
import { trackForcedToolUsage } from '@/providers/utils'
const logger = createLogger('AnthropicUtils')
export interface AnthropicStreamUsage {
input_tokens: number
output_tokens: number
}
export function createReadableStreamFromAnthropicStream(
anthropicStream: AsyncIterable<RawMessageStreamEvent>,
onComplete?: (content: string, usage: AnthropicStreamUsage) => void
): ReadableStream<Uint8Array> {
let fullContent = ''
let inputTokens = 0
let outputTokens = 0
return new ReadableStream({
async start(controller) {
try {
for await (const event of anthropicStream) {
if (event.type === 'message_start') {
const startEvent = event as RawMessageStartEvent
const usage: Usage = startEvent.message.usage
inputTokens = usage.input_tokens
} else if (event.type === 'message_delta') {
const deltaEvent = event as RawMessageDeltaEvent
outputTokens = deltaEvent.usage.output_tokens
} else if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
const text = event.delta.text
fullContent += text
controller.enqueue(new TextEncoder().encode(text))
}
}
if (onComplete) {
onComplete(fullContent, { input_tokens: inputTokens, output_tokens: outputTokens })
}
controller.close()
} catch (err) {
controller.error(err)
}
},
})
}
export function generateToolUseId(toolName: string): string {
return `${toolName}-${Date.now()}-${randomFloat().toString(36).substring(2, 7)}`
}
export function checkForForcedToolUsage(
response: any,
toolChoice: any,
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } | null {
if (typeof toolChoice === 'object' && toolChoice !== null && Array.isArray(response.content)) {
const toolUses = response.content.filter((item: any) => item.type === 'tool_use')
if (toolUses.length > 0) {
const adaptedToolCalls = toolUses.map((tool: any) => ({ name: tool.name }))
const adaptedToolChoice =
toolChoice.type === 'tool' ? { function: { name: toolChoice.name } } : toolChoice
return trackForcedToolUsage(
adaptedToolCalls,
adaptedToolChoice,
logger,
'anthropic',
forcedTools,
usedForcedTools
)
}
}
return null
}
+407
View File
@@ -0,0 +1,407 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { UserFile } from '@/executor/types'
import {
buildAnthropicMessageContent,
buildBedrockMessageContent,
buildGeminiMessageParts,
buildOpenAICompatibleChatContent,
buildOpenAIMessageContent,
buildOpenRouterMessageContent,
formatMessagesForProvider,
getProviderAttachmentMaxBytes,
getProviderFileStrategy,
INLINE_ATTACHMENT_THRESHOLD_BYTES,
inferAttachmentMimeType,
prepareProviderAttachments,
shouldUseLargeFilePath,
} from '@/providers/attachments'
const imageFile: UserFile = {
id: 'file-1',
name: 'example.png',
url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace',
size: 128,
type: 'image/png',
key: 'workspace/ws-1/example.png',
base64: 'iVBORw0KGgo=',
}
const pdfFile: UserFile = {
id: 'file-2',
name: 'example.pdf',
url: '/api/files/serve/workspace%2Fws-1%2Fexample.pdf?context=workspace',
size: 256,
type: 'application/pdf',
key: 'workspace/ws-1/example.pdf',
base64: 'cGRm',
}
const markdownFile: UserFile = {
id: 'file-3',
name: 'notes.md',
url: '/api/files/serve/workspace%2Fws-1%2Fnotes.md?context=workspace',
size: 17,
type: 'text/markdown',
key: 'workspace/ws-1/notes.md',
base64: Buffer.from('# Notes\n\nHello').toString('base64'),
}
describe('provider attachments', () => {
it('infers MIME type from filename when file type is generic', () => {
expect(
inferAttachmentMimeType({
...imageFile,
type: 'application/octet-stream',
})
).toBe('image/png')
})
it('formats OpenAI Responses content with text, image, and file parts', () => {
const content = buildOpenAIMessageContent(
'Analyze these files',
[imageFile, pdfFile, markdownFile],
'openai'
)
expect(content).toEqual([
{ type: 'input_text', text: 'Analyze these files' },
{
type: 'input_image',
image_url: 'data:image/png;base64,iVBORw0KGgo=',
detail: 'auto',
},
{
type: 'input_file',
filename: 'example.pdf',
file_data: 'data:application/pdf;base64,cGRm',
},
{
type: 'input_file',
filename: 'notes.md',
file_data: `data:text/markdown;base64,${markdownFile.base64}`,
},
])
})
it('formats Anthropic content with image, PDF document, and text document blocks', () => {
const content = buildAnthropicMessageContent(
'Analyze these files',
[imageFile, pdfFile, markdownFile],
'anthropic'
)
expect(content).toEqual([
{ type: 'text', text: 'Analyze these files' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
{
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: 'cGRm',
},
title: 'example.pdf',
},
{
type: 'document',
source: {
type: 'text',
media_type: 'text/plain',
data: '# Notes\n\nHello',
},
title: 'notes.md',
},
])
})
it('formats Gemini content with text and inline data parts', () => {
const parts = buildGeminiMessageParts('Analyze this file', [imageFile, markdownFile], 'google')
expect(parts).toEqual([
{ text: 'Analyze this file' },
{
inlineData: {
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
},
{
inlineData: {
mimeType: 'text/plain',
data: markdownFile.base64,
},
},
])
})
it('formats Bedrock content with native document blocks', () => {
const parts = buildBedrockMessageContent('Analyze this file', [markdownFile], 'bedrock')
expect(parts).toEqual([
{ text: 'Analyze this file' },
{
document: {
format: 'md',
name: 'notes',
source: {
bytes: Buffer.from(markdownFile.base64, 'base64'),
},
},
},
])
})
it('formats OpenRouter images and PDFs with native multimodal message parts', () => {
const content = buildOpenRouterMessageContent(
'Analyze these files',
[imageFile, pdfFile],
'openrouter'
)
expect(content).toEqual([
{ type: 'text', text: 'Analyze these files' },
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
{
type: 'file',
file: {
filename: 'example.pdf',
file_data: 'data:application/pdf;base64,cGRm',
},
},
])
})
it('formats image-only provider messages and strips file fields', () => {
const messages = formatMessagesForProvider(
[{ role: 'user', content: 'Analyze this image', files: [imageFile] }],
'groq'
)
expect(messages).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'Analyze this image' },
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
],
},
])
})
it('fails fast for unsupported MIME types', () => {
expect(() =>
prepareProviderAttachments(
[
{
...imageFile,
name: 'archive.zip',
type: 'application/zip',
},
],
'openai'
)
).toThrow('application/zip')
})
it('sniffs image bytes and corrects a wrong declared image MIME type', () => {
const content = buildAnthropicMessageContent(
'Analyze this image',
[
{
...imageFile,
name: 'wrong.ico',
type: 'image/x-icon',
},
],
'anthropic'
)
expect(content[1]).toEqual({
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
})
})
it('rejects image attachments when the bytes are not a supported image format', () => {
expect(() =>
prepareProviderAttachments(
[
{
...imageFile,
name: 'not-an-image.png',
base64: Buffer.from('not an image').toString('base64'),
},
],
'anthropic'
)
).toThrow('not a supported model image format')
})
it('rejects documents for image-only providers', () => {
expect(() =>
formatMessagesForProvider(
[{ role: 'user', content: 'Analyze this file', files: [pdfFile] }],
'groq'
)
).toThrow('Supported attachments: images')
})
it('rejects providers without file attachment support', () => {
expect(() =>
formatMessagesForProvider(
[{ role: 'user', content: 'Analyze this file', files: [imageFile] }],
'deepseek'
)
).toThrow('not supported')
})
})
describe('provider large-file capability', () => {
it('reports per-provider strategy and ceiling, defaulting others to inline', () => {
expect(getProviderFileStrategy('openai')).toBe('files-api')
expect(getProviderFileStrategy('google')).toBe('files-api')
expect(getProviderFileStrategy('anthropic')).toBe('remote-url')
expect(getProviderFileStrategy('groq')).toBe('remote-url')
expect(getProviderFileStrategy('bedrock')).toBe('inline')
expect(getProviderFileStrategy('azure-openai')).toBe('inline')
expect(getProviderFileStrategy('vertex')).toBe('inline')
expect(getProviderAttachmentMaxBytes('openai')).toBeGreaterThan(
INLINE_ATTACHMENT_THRESHOLD_BYTES
)
expect(getProviderAttachmentMaxBytes('bedrock')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES)
expect(getProviderAttachmentMaxBytes('azure-openai')).toBe(INLINE_ATTACHMENT_THRESHOLD_BYTES)
})
it('routes only oversized files on capable providers to the large-file path', () => {
const small = { ...imageFile, size: 1024 }
const large = { ...imageFile, size: INLINE_ATTACHMENT_THRESHOLD_BYTES + 1 }
expect(shouldUseLargeFilePath(small, 'openai')).toBe(false)
expect(shouldUseLargeFilePath(large, 'openai')).toBe(true)
expect(shouldUseLargeFilePath(large, 'bedrock')).toBe(false)
})
it('references uploaded OpenAI files by file_id instead of inlining base64', () => {
const content = buildOpenAIMessageContent(
'Analyze',
[
{ ...imageFile, base64: undefined, providerFileId: 'file-img' },
{ ...pdfFile, base64: undefined, providerFileId: 'file-doc' },
],
'openai'
)
expect(content).toEqual([
{ type: 'input_text', text: 'Analyze' },
{ type: 'input_image', file_id: 'file-img', detail: 'auto' },
{ type: 'input_file', file_id: 'file-doc' },
])
})
it('references large Anthropic files via url content-block sources', () => {
const content = buildAnthropicMessageContent(
'Analyze',
[
{ ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' },
{ ...pdfFile, base64: undefined, remoteUrl: 'https://signed/doc.pdf' },
],
'anthropic'
)
expect(content).toEqual([
{ type: 'text', text: 'Analyze' },
{ type: 'image', source: { type: 'url', url: 'https://signed/img.png' } },
{
type: 'document',
source: { type: 'url', url: 'https://signed/doc.pdf' },
title: 'example.pdf',
},
])
})
it('references uploaded Gemini files via fileData uri', () => {
const parts = buildGeminiMessageParts(
'Analyze',
[{ ...imageFile, base64: undefined, providerFileUri: 'https://files/abc' }],
'google'
)
expect(parts).toEqual([
{ text: 'Analyze' },
{ fileData: { fileUri: 'https://files/abc', mimeType: 'image/png' } },
])
})
it('passes a remote url to OpenAI-compatible providers instead of a data url', () => {
const content = buildOpenAICompatibleChatContent(
'Analyze',
[{ ...imageFile, base64: undefined, remoteUrl: 'https://signed/img.png' }],
'groq'
)
expect(content).toEqual([
{ type: 'text', text: 'Analyze' },
{ type: 'image_url', image_url: { url: 'https://signed/img.png' } },
])
})
it('rejects oversized non-PDF text documents on Anthropic (url source supports PDFs/images only)', () => {
expect(() =>
buildAnthropicMessageContent(
'Analyze',
[
{
...markdownFile,
type: 'text/csv',
name: 'data.csv',
base64: undefined,
remoteUrl: 'https://signed/data.csv',
},
],
'anthropic'
)
).toThrow('Only PDFs and images are supported')
})
it('references large Anthropic PDFs via a url document source', () => {
const content = buildAnthropicMessageContent(
'Analyze',
[{ ...pdfFile, base64: undefined, remoteUrl: 'https://signed/doc.pdf' }],
'anthropic'
)
expect(content).toEqual([
{ type: 'text', text: 'Analyze' },
{
type: 'document',
source: { type: 'url', url: 'https://signed/doc.pdf' },
title: 'example.pdf',
},
])
})
it('rejects files above the provider ceiling', () => {
const huge = {
...imageFile,
size: getProviderAttachmentMaxBytes('openai') + 1,
base64: undefined,
providerFileId: 'file-img',
}
expect(() => buildOpenAIMessageContent('Analyze', [huge], 'openai')).toThrow('exceeds the')
})
})
+738
View File
@@ -0,0 +1,738 @@
import type Anthropic from '@anthropic-ai/sdk'
import type { ContentBlock } from '@aws-sdk/client-bedrock-runtime'
import type { Part } from '@google/genai'
import type OpenAI from 'openai'
import {
getContentType,
getExtensionFromMimeType,
getFileExtension,
getMimeTypeFromExtension,
MIME_TYPE_MAPPING,
MODEL_SUPPORTED_IMAGE_MIME_TYPES,
} from '@/lib/uploads/utils/file-utils'
import type { UserFile } from '@/executor/types'
import {
getProviderFileAttachment,
INLINE_ATTACHMENT_MAX_BYTES,
type ProviderFileAttachmentStrategy,
} from '@/providers/models'
import type { ProviderId } from '@/providers/types'
export type AttachmentProvider =
| 'openai'
| 'anthropic'
| 'google'
| 'bedrock'
| 'openrouter'
| 'mistral'
| 'groq'
| 'fireworks'
| 'together'
| 'baseten'
| 'ollama'
| 'vllm'
| 'litellm'
| 'xai'
| 'deepseek'
| 'cerebras'
| 'sakana'
| 'nvidia'
| 'meta'
| 'zai'
export interface PreparedProviderAttachment {
file: UserFile
filename: string
mimeType: string
providerMimeType: string
/** Base64 payload — present only for inlined files (≤ inline threshold). Absent for large uploaded files. */
base64?: string
/** `data:` URL — present only for inlined files. Absent for large uploaded files. */
dataUrl?: string
text?: string
extension: string
contentType: 'image' | 'document' | 'audio' | 'video'
/** Provider Files API id (OpenAI/Anthropic) when the file was uploaded instead of inlined. */
providerFileId?: string
/** Provider File API uri (Gemini) when the file was uploaded instead of inlined. */
providerFileUri?: string
/** Short-lived signed HTTPS URL for providers that fetch attachments by remote URL. */
remoteUrl?: string
}
type ProviderMessageInput = {
role: string
content?: string | null
files?: UserFile[]
}
type ProviderFormattedMessage = {
role: string
content?: string | null | Array<Record<string, unknown>>
files?: UserFile[]
[key: string]: unknown
}
/**
* Files at or below this size are inlined as base64, exactly as before. Larger files take
* the provider's large-file path. Keeping the threshold at the legacy 10 MB cap guarantees
* identical behaviour for existing attachments.
*/
export const INLINE_ATTACHMENT_THRESHOLD_BYTES = INLINE_ATTACHMENT_MAX_BYTES
export type ProviderFileStrategy = ProviderFileAttachmentStrategy
/** Large-file delivery strategy for a provider, sourced from its `models.ts` definition. */
export function getProviderFileStrategy(providerId: ProviderId | string): ProviderFileStrategy {
return getProviderFileAttachment(providerId).strategy
}
/** True when a file exceeds the inline threshold and the provider has a large-file path. */
export function shouldUseLargeFilePath(
file: Pick<UserFile, 'size'>,
providerId: ProviderId | string
): boolean {
if (getProviderFileAttachment(providerId).strategy === 'inline') return false
return Number.isFinite(file.size) && file.size > INLINE_ATTACHMENT_THRESHOLD_BYTES
}
const PDF_MIME_TYPE = 'application/pdf'
const DOCUMENT_MIME_TYPES = new Set(
Object.entries(MIME_TYPE_MAPPING)
.filter(([, contentType]) => contentType === 'document')
.map(([mimeType]) => mimeType)
)
const OPENAI_DOCUMENT_MIME_TYPES = new Set([...DOCUMENT_MIME_TYPES, 'application/x-yaml'])
const GEMINI_INLINE_MIME_TYPES = new Set([...Object.keys(MIME_TYPE_MAPPING), 'application/x-yaml'])
const BEDROCK_DOCUMENT_FORMATS = new Set([
'pdf',
'csv',
'doc',
'docx',
'xls',
'xlsx',
'html',
'txt',
'md',
])
const BEDROCK_IMAGE_FORMATS = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp'])
const BEDROCK_VIDEO_FORMATS = new Set(['mp4', 'mov', 'mkv', 'webm'])
const UNSUPPORTED_FILE_PROVIDERS = new Set<AttachmentProvider>([
'deepseek',
'cerebras',
'sakana',
'nvidia',
'meta',
'zai',
])
const PROVIDER_SUPPORTED_LABELS: Record<AttachmentProvider, string> = {
openai: 'images and documents through the Responses API input_image/input_file parts',
anthropic: 'images, PDFs, and text documents through Claude content blocks',
google: 'images, audio, video, PDFs, and text documents through Gemini inlineData',
bedrock: 'Bedrock Converse image, document, and video content blocks',
openrouter: 'images and PDFs through OpenRouter multimodal message parts',
mistral: 'images through image_url message parts',
groq: 'images through image_url message parts on multimodal models',
fireworks: 'images through image_url message parts on vision models',
together: 'images through image_url message parts on vision models',
baseten: 'images through image_url message parts on vision models',
ollama: 'images through image_url message parts on vision models',
vllm: 'images through image_url message parts on multimodal models',
litellm: 'images through image_url message parts on multimodal models',
xai: 'images through image_url message parts on Grok vision models',
deepseek: 'no file attachments in the current API adapter',
cerebras: 'no file attachments in the current API adapter',
sakana: 'no file attachments in the current API adapter',
nvidia: 'no file attachments in the current API adapter',
meta: 'no file attachments in the current API adapter',
zai: 'no file attachments in the current API adapter',
}
export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null {
if (providerId === 'openai' || providerId === 'azure-openai') return 'openai'
if (providerId === 'anthropic' || providerId === 'azure-anthropic') return 'anthropic'
if (providerId === 'google' || providerId === 'vertex') return 'google'
if (providerId === 'bedrock') return 'bedrock'
if (providerId === 'openrouter') return 'openrouter'
if (providerId === 'mistral') return 'mistral'
if (providerId === 'groq') return 'groq'
if (providerId === 'fireworks') return 'fireworks'
if (providerId === 'together') return 'together'
if (providerId === 'baseten') return 'baseten'
if (providerId === 'ollama' || providerId === 'ollama-cloud') return 'ollama'
if (providerId === 'vllm') return 'vllm'
if (providerId === 'litellm') return 'litellm'
if (providerId === 'xai') return 'xai'
if (providerId === 'deepseek') return 'deepseek'
if (providerId === 'cerebras') return 'cerebras'
if (providerId === 'sakana') return 'sakana'
if (providerId === 'nvidia') return 'nvidia'
if (providerId === 'meta') return 'meta'
if (providerId === 'zai') return 'zai'
return null
}
export function supportsFileAttachments(providerId: ProviderId | string): boolean {
const provider = getAttachmentProvider(providerId)
return Boolean(provider && !UNSUPPORTED_FILE_PROVIDERS.has(provider))
}
/**
* Real maximum attachment size for a provider — its native ceiling when it has a large-file
* path, else the inline base64 threshold. Used for UI limits and validation, never as the
* base64 hydration cap (which stays at {@link INLINE_ATTACHMENT_THRESHOLD_BYTES}).
*/
export function getProviderAttachmentMaxBytes(providerId: ProviderId | string): number {
return getProviderFileAttachment(providerId).maxBytes
}
export function inferAttachmentMimeType(file: UserFile): string {
const explicitType = file.type?.trim().toLowerCase()
if (explicitType && explicitType !== 'application/octet-stream') {
return explicitType
}
const inferred = getMimeTypeFromExtension(getFileExtension(file.name))
return inferred.toLowerCase()
}
function isTextDocumentMimeType(mimeType: string): boolean {
return (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml' ||
mimeType === 'application/x-yaml'
)
}
function isImageMimeType(mimeType: string): boolean {
return MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)
}
function isOpenAIDocumentMimeType(mimeType: string): boolean {
return OPENAI_DOCUMENT_MIME_TYPES.has(mimeType) || isTextDocumentMimeType(mimeType)
}
function getAttachmentContentType(
mimeType: string
): PreparedProviderAttachment['contentType'] | null {
return getContentType(mimeType) || (isTextDocumentMimeType(mimeType) ? 'document' : null)
}
function sniffImageMimeType(base64: string): string {
let bytes: Buffer
try {
bytes = Buffer.from(base64, 'base64')
} catch {
return ''
}
if (
bytes.length >= 8 &&
bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) {
return 'image/png'
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg'
}
if (
bytes.length >= 6 &&
(bytes.subarray(0, 6).equals(Buffer.from('GIF87a')) ||
bytes.subarray(0, 6).equals(Buffer.from('GIF89a')))
) {
return 'image/gif'
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).equals(Buffer.from('RIFF')) &&
bytes.subarray(8, 12).equals(Buffer.from('WEBP'))
) {
return 'image/webp'
}
return ''
}
function getAttachmentExtension(file: UserFile, mimeType: string): string {
if (mimeType === 'text/markdown') return 'md'
return getExtensionFromMimeType(mimeType) || getFileExtension(file.name)
}
function normalizeProviderMimeType(mimeType: string, provider: AttachmentProvider): string {
if ((provider === 'anthropic' || provider === 'google') && isTextDocumentMimeType(mimeType)) {
return 'text/plain'
}
return mimeType
}
function decodeBase64Text(base64: string, filename: string): string {
try {
return Buffer.from(base64, 'base64').toString('utf8')
} catch {
throw new Error(`File "${filename}" could not be decoded as UTF-8 text`)
}
}
function toDataUrl(mimeType: string, base64: string): string {
return `data:${mimeType};base64,${base64}`
}
function isMimeTypeSupportedByProvider(
provider: AttachmentProvider,
mimeType: string,
contentType: PreparedProviderAttachment['contentType'],
extension: string
): boolean {
switch (provider) {
case 'openai':
return isImageMimeType(mimeType) || isOpenAIDocumentMimeType(mimeType)
case 'anthropic':
return (
isImageMimeType(mimeType) || mimeType === PDF_MIME_TYPE || isTextDocumentMimeType(mimeType)
)
case 'google':
return GEMINI_INLINE_MIME_TYPES.has(mimeType) || isTextDocumentMimeType(mimeType)
case 'bedrock':
return (
(contentType === 'image' && BEDROCK_IMAGE_FORMATS.has(extension)) ||
(contentType === 'document' && BEDROCK_DOCUMENT_FORMATS.has(extension)) ||
(contentType === 'video' && BEDROCK_VIDEO_FORMATS.has(extension))
)
case 'openrouter':
return isImageMimeType(mimeType) || mimeType === PDF_MIME_TYPE
case 'mistral':
case 'groq':
case 'fireworks':
case 'together':
case 'baseten':
case 'ollama':
case 'vllm':
case 'litellm':
case 'xai':
return isImageMimeType(mimeType)
case 'deepseek':
case 'cerebras':
case 'sakana':
case 'nvidia':
case 'meta':
case 'zai':
return false
default: {
const _exhaustive: never = provider
return _exhaustive
}
}
}
function validateProviderSupport(
attachment: Omit<PreparedProviderAttachment, 'providerMimeType' | 'dataUrl' | 'text'>,
provider: AttachmentProvider,
providerId: ProviderId | string
) {
const { filename, mimeType, contentType, extension } = attachment
const supportedLabel = PROVIDER_SUPPORTED_LABELS[provider]
const supported = isMimeTypeSupportedByProvider(provider, mimeType, contentType, extension)
if (!supported) {
throw new Error(
`File "${filename}" has MIME type "${mimeType}", which is not supported by provider "${providerId}". Supported attachments: ${supportedLabel}.`
)
}
}
export function prepareProviderAttachments(
files: UserFile[] | undefined,
providerId: ProviderId | string
): PreparedProviderAttachment[] {
if (!files || files.length === 0) return []
const provider = getAttachmentProvider(providerId)
if (!provider) {
throw new Error(`File attachments are not supported for provider "${providerId}"`)
}
if (UNSUPPORTED_FILE_PROVIDERS.has(provider)) {
throw new Error(
`File attachments are not supported for provider "${providerId}" in the current adapter. Supported attachments: ${PROVIDER_SUPPORTED_LABELS[provider]}.`
)
}
return files.map((file) => {
const declaredMimeType = inferAttachmentMimeType(file)
const contentType = getAttachmentContentType(declaredMimeType)
if (!contentType) {
throw new Error(
`File "${file.name}" has MIME type "${declaredMimeType}", which is not supported by provider "${providerId}". Supported attachments: ${PROVIDER_SUPPORTED_LABELS[provider]}.`
)
}
const maxBytes = getProviderAttachmentMaxBytes(providerId)
if (Number.isFinite(file.size) && file.size > maxBytes) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2)
const maxMB = (maxBytes / (1024 * 1024)).toFixed(0)
throw new Error(
`File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"`
)
}
const providerFileId = file.providerFileId
const providerFileUri = file.providerFileUri
const remoteUrl = file.remoteUrl
const hasHandle = Boolean(providerFileId || providerFileUri || remoteUrl)
if (!file.base64 && !hasHandle) {
throw new Error(`File "${file.name}" could not be read for provider "${providerId}"`)
}
const sniffedImageMimeType =
contentType === 'image' && file.base64 ? sniffImageMimeType(file.base64) : ''
if (contentType === 'image' && file.base64 && !sniffedImageMimeType) {
throw new Error(
`Image bytes in "${file.name}" are not a supported model image format (declared MIME type "${declaredMimeType}"). Supported image formats: image/jpeg, image/png, image/gif, image/webp.`
)
}
const mimeType = sniffedImageMimeType || declaredMimeType
const extension = getAttachmentExtension(file, mimeType)
const attachment = {
file,
filename: file.name,
mimeType,
base64: file.base64,
extension,
contentType,
}
validateProviderSupport(attachment, provider, providerId)
const providerMimeType = normalizeProviderMimeType(mimeType, provider)
return {
...attachment,
providerMimeType,
providerFileId,
providerFileUri,
remoteUrl,
...(file.base64 && { dataUrl: toDataUrl(providerMimeType, file.base64) }),
...(file.base64 &&
isTextDocumentMimeType(mimeType) && {
text: decodeBase64Text(file.base64, file.name),
}),
}
})
}
type OpenAIResponsesInputContent = OpenAI.Responses.ResponseInputContent
type OpenAIChatContentPart = OpenAI.Chat.Completions.ChatCompletionContentPart
type AnthropicImageMediaType = Anthropic.Messages.Base64ImageSource['media_type']
export function buildOpenAIMessageContent(
content: string | null | undefined,
files: UserFile[] | undefined,
providerId: ProviderId | string
): string | OpenAIResponsesInputContent[] {
const attachments = prepareProviderAttachments(files, providerId)
if (attachments.length === 0) return content ?? ''
const parts: OpenAIResponsesInputContent[] = []
if (content) {
parts.push({ type: 'input_text', text: content } satisfies OpenAI.Responses.ResponseInputText)
}
for (const attachment of attachments) {
if (attachment.contentType === 'image') {
parts.push(
attachment.providerFileId
? ({
type: 'input_image',
file_id: attachment.providerFileId,
detail: 'auto',
} satisfies OpenAI.Responses.ResponseInputImage)
: ({
type: 'input_image',
image_url: attachment.dataUrl,
detail: 'auto',
} satisfies OpenAI.Responses.ResponseInputImage)
)
} else {
parts.push(
attachment.providerFileId
? ({
type: 'input_file',
file_id: attachment.providerFileId,
} satisfies OpenAI.Responses.ResponseInputFile)
: ({
type: 'input_file',
filename: attachment.filename,
file_data: attachment.dataUrl,
} satisfies OpenAI.Responses.ResponseInputFile)
)
}
}
return parts
}
export function buildAnthropicMessageContent(
content: string | null | undefined,
files: UserFile[] | undefined,
providerId: ProviderId | string
): Anthropic.Messages.ContentBlockParam[] {
const parts: Anthropic.Messages.ContentBlockParam[] = []
if (content) {
parts.push({ type: 'text', text: content } satisfies Anthropic.Messages.TextBlockParam)
}
for (const attachment of prepareProviderAttachments(files, providerId)) {
if (attachment.contentType === 'image') {
parts.push({
type: 'image',
source: attachment.remoteUrl
? ({ type: 'url', url: attachment.remoteUrl } satisfies Anthropic.Messages.URLImageSource)
: ({
type: 'base64',
media_type: attachment.providerMimeType as AnthropicImageMediaType,
data: attachment.base64 ?? '',
} satisfies Anthropic.Messages.Base64ImageSource),
} satisfies Anthropic.Messages.ImageBlockParam)
} else if (attachment.remoteUrl) {
if (attachment.mimeType !== PDF_MIME_TYPE) {
throw new Error(
`Document "${attachment.filename}" (${attachment.mimeType}) is too large to send to provider "${providerId}". Only PDFs and images are supported above the inline limit — convert it to PDF or reduce its size.`
)
}
parts.push({
type: 'document',
source: { type: 'url', url: attachment.remoteUrl },
title: attachment.filename,
} satisfies Anthropic.Messages.DocumentBlockParam)
} else if (attachment.text) {
parts.push({
type: 'document',
source: {
type: 'text',
media_type: 'text/plain',
data: attachment.text,
},
title: attachment.filename,
} satisfies Anthropic.Messages.DocumentBlockParam)
} else {
parts.push({
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: attachment.base64 ?? '',
},
title: attachment.filename,
} satisfies Anthropic.Messages.DocumentBlockParam)
}
}
return parts
}
export function buildGeminiMessageParts(
content: string | null | undefined,
files: UserFile[] | undefined,
providerId: ProviderId | string
): Part[] {
const parts: Part[] = []
if (content) {
parts.push({ text: content } satisfies Part)
}
for (const attachment of prepareProviderAttachments(files, providerId)) {
parts.push(
attachment.providerFileUri
? ({
fileData: {
fileUri: attachment.providerFileUri,
mimeType: attachment.providerMimeType,
},
} satisfies Part)
: ({
inlineData: {
mimeType: attachment.providerMimeType,
data: attachment.base64 ?? '',
},
} satisfies Part)
)
}
return parts
}
export function buildOpenAICompatibleChatContent(
content: string | null | undefined,
files: UserFile[] | undefined,
providerId: ProviderId | string
): string | OpenAIChatContentPart[] {
const attachments = prepareProviderAttachments(files, providerId)
if (attachments.length === 0) return content ?? ''
const parts: OpenAIChatContentPart[] = []
if (content) {
parts.push({
type: 'text',
text: content,
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartText)
}
for (const attachment of attachments) {
parts.push({
type: 'image_url',
image_url: {
url: attachment.remoteUrl ?? attachment.dataUrl ?? '',
},
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartImage)
}
return parts
}
export function buildOpenRouterMessageContent(
content: string | null | undefined,
files: UserFile[] | undefined,
providerId: ProviderId | string
): string | OpenAIChatContentPart[] {
const attachments = prepareProviderAttachments(files, providerId)
if (attachments.length === 0) return content ?? ''
const parts: OpenAIChatContentPart[] = []
if (content) {
parts.push({
type: 'text',
text: content,
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartText)
}
for (const attachment of attachments) {
if (attachment.contentType === 'image') {
parts.push({
type: 'image_url',
image_url: { url: attachment.remoteUrl ?? attachment.dataUrl ?? '' },
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPartImage)
} else {
parts.push({
type: 'file',
file: {
filename: attachment.filename,
file_data: attachment.remoteUrl ?? attachment.dataUrl ?? '',
},
} satisfies OpenAI.Chat.Completions.ChatCompletionContentPart.File)
}
}
return parts
}
function sanitizeBedrockName(filename: string): string {
const baseName = filename.replace(/\.[^/.]+$/, '').replace(/[^a-zA-Z0-9\s()[\]-]/g, ' ')
const compacted = baseName.replace(/\s+/g, ' ').trim()
return compacted || 'Document'
}
function getBedrockDocumentFormat(attachment: PreparedProviderAttachment): string {
if (attachment.extension === 'md' || attachment.mimeType === 'text/markdown') return 'md'
if (attachment.extension === 'txt' || attachment.mimeType === 'text/plain') return 'txt'
return attachment.extension || 'txt'
}
function getBedrockImageFormat(attachment: PreparedProviderAttachment): string {
return attachment.extension === 'jpg' ? 'jpeg' : attachment.extension
}
export function buildBedrockMessageContent(
content: string | null | undefined,
files: UserFile[] | undefined,
providerId: ProviderId | string
): ContentBlock[] {
const parts: ContentBlock[] = []
if (content) {
parts.push({ text: content } as ContentBlock.TextMember)
}
for (const attachment of prepareProviderAttachments(files, providerId)) {
const bytes = Buffer.from(attachment.base64 ?? '', 'base64')
if (attachment.contentType === 'image') {
parts.push({
image: {
format: getBedrockImageFormat(attachment) as ContentBlock.ImageMember['image']['format'],
source: { bytes },
},
} as ContentBlock.ImageMember)
} else if (attachment.contentType === 'video') {
parts.push({
video: {
format: attachment.extension as ContentBlock.VideoMember['video']['format'],
source: { bytes },
},
} as ContentBlock.VideoMember)
} else {
parts.push({
document: {
format: getBedrockDocumentFormat(
attachment
) as ContentBlock.DocumentMember['document']['format'],
name: sanitizeBedrockName(attachment.filename),
source: { bytes },
},
} as ContentBlock.DocumentMember)
}
}
return parts
}
const SDK_NATIVE_ATTACHMENT_PROVIDERS = new Set<AttachmentProvider>([
'openai',
'anthropic',
'google',
'bedrock',
])
export function formatMessagesForProvider(
messages: ProviderMessageInput[],
providerId: ProviderId | string
): ProviderFormattedMessage[] {
const provider = getAttachmentProvider(providerId)
if (provider && SDK_NATIVE_ATTACHMENT_PROVIDERS.has(provider)) {
return messages as ProviderFormattedMessage[]
}
return messages.map((message) => {
if (!message.files?.length || (message.role !== 'user' && message.role !== 'assistant')) {
return message as ProviderFormattedMessage
}
if (provider === 'openrouter') {
const { files: _omit, ...rest } = message
return {
...rest,
content: buildOpenRouterMessageContent(message.content, message.files, providerId) as
| string
| Array<Record<string, unknown>>,
}
}
const { files: _omit, ...rest } = message
return {
...rest,
content: buildOpenAICompatibleChatContent(message.content, message.files, providerId) as
| string
| Array<Record<string, unknown>>,
}
})
}
@@ -0,0 +1,124 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProviderRequest } from '@/providers/types'
const {
mockAnthropic,
anthropicArgs,
mockValidate,
mockCreatePinnedFetch,
mockExecuteAnthropic,
sentinelFetch,
envState,
} = vi.hoisted(() => {
const anthropicArgs: Array<Record<string, unknown>> = []
const sentinelFetch = vi.fn()
class MockAnthropic {
constructor(opts: Record<string, unknown>) {
anthropicArgs.push(opts)
}
}
return {
mockAnthropic: MockAnthropic,
anthropicArgs,
mockValidate: vi.fn(),
mockCreatePinnedFetch: vi.fn(() => sentinelFetch),
mockExecuteAnthropic: vi.fn(),
sentinelFetch,
envState: {
AZURE_ANTHROPIC_ENDPOINT: undefined as string | undefined,
AZURE_ANTHROPIC_API_VERSION: undefined as string | undefined,
},
}
})
vi.mock('@anthropic-ai/sdk', () => ({ default: mockAnthropic }))
vi.mock('@/lib/core/config/env', () => ({ env: envState }))
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: mockValidate,
createPinnedFetch: mockCreatePinnedFetch,
}))
vi.mock('@/providers/anthropic/core', () => ({
executeAnthropicProviderRequest: mockExecuteAnthropic,
}))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn(() => []),
getProviderDefaultModel: vi.fn(() => 'azure-anthropic/claude'),
}))
import { azureAnthropicProvider } from '@/providers/azure-anthropic/index'
function request(overrides: Partial<ProviderRequest>): ProviderRequest {
return { model: 'azure-anthropic/claude-3-5-sonnet', apiKey: 'k', messages: [], ...overrides }
}
/** Invokes the createClient factory handed to the Anthropic core and returns the SDK options it built. */
function buildClientOptions(): Record<string, unknown> {
const config = mockExecuteAnthropic.mock.calls[0][1]
config.createClient('k', false)
return anthropicArgs[0]
}
describe('azureAnthropicProvider — SSRF pinning', () => {
beforeEach(() => {
vi.clearAllMocks()
anthropicArgs.length = 0
envState.AZURE_ANTHROPIC_ENDPOINT = undefined
envState.AZURE_ANTHROPIC_API_VERSION = undefined
mockExecuteAnthropic.mockResolvedValue({ content: 'ok' })
})
it('validates and pins the connection to the resolved IP for a user-supplied endpoint', async () => {
mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
await azureAnthropicProvider.executeRequest(
request({ azureEndpoint: 'https://rebind.attacker.tld' })
)
expect(mockValidate).toHaveBeenCalledWith('https://rebind.attacker.tld', 'azureEndpoint')
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
expect(buildClientOptions()).toMatchObject({ fetch: sentinelFetch })
})
it('does not pin when the endpoint comes from trusted server env', async () => {
envState.AZURE_ANTHROPIC_ENDPOINT = 'https://trusted.services.ai.azure.com'
await azureAnthropicProvider.executeRequest(request({ azureEndpoint: undefined }))
expect(mockValidate).not.toHaveBeenCalled()
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(buildClientOptions()).not.toHaveProperty('fetch')
})
it('throws and never builds a client when validation blocks the endpoint', async () => {
mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' })
await expect(
azureAnthropicProvider.executeRequest(
request({ azureEndpoint: 'https://rebind.attacker.tld' })
)
).rejects.toThrow('Invalid Azure Anthropic endpoint')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockExecuteAnthropic).not.toHaveBeenCalled()
})
it('fails closed when validation passes but yields no resolvable IP to pin', async () => {
mockValidate.mockResolvedValue({ isValid: true })
await expect(
azureAnthropicProvider.executeRequest(
request({ azureEndpoint: 'https://rebind.attacker.tld' })
)
).rejects.toThrow('could not resolve a pinnable IP address')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockExecuteAnthropic).not.toHaveBeenCalled()
})
})
+104
View File
@@ -0,0 +1,104 @@
import Anthropic from '@anthropic-ai/sdk'
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import type { StreamingExecution } from '@/executor/types'
import { executeAnthropicProviderRequest } from '@/providers/anthropic/core'
import { getCachedProviderClient } from '@/providers/client-cache'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
const logger = createLogger('AzureAnthropicProvider')
export const azureAnthropicProvider: ProviderConfig = {
id: 'azure-anthropic',
name: 'Azure Anthropic',
description: 'Anthropic Claude models via Azure AI Foundry',
version: '1.0.0',
models: getProviderModels('azure-anthropic'),
defaultModel: getProviderDefaultModel('azure-anthropic'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
const userProvidedEndpoint = request.azureEndpoint
const azureEndpoint = userProvidedEndpoint || env.AZURE_ANTHROPIC_ENDPOINT
if (!azureEndpoint) {
throw new Error(
'Azure endpoint is required for Azure Anthropic. Please provide it via the azureEndpoint parameter or AZURE_ANTHROPIC_ENDPOINT environment variable.'
)
}
let pinnedFetch: typeof fetch | undefined
let pinnedIP: string | undefined
if (userProvidedEndpoint) {
const validation = await validateUrlWithDNS(userProvidedEndpoint, 'azureEndpoint')
if (!validation.isValid) {
logger.warn('Blocked SSRF attempt via azureEndpoint', {
endpoint: userProvidedEndpoint,
error: validation.error,
})
throw new Error(`Invalid Azure Anthropic endpoint: ${validation.error}`)
}
if (!validation.resolvedIP) {
throw new Error('Invalid Azure Anthropic endpoint: could not resolve a pinnable IP address')
}
pinnedIP = validation.resolvedIP
pinnedFetch = createPinnedFetch(pinnedIP)
}
const apiKey = request.apiKey
if (!apiKey) {
throw new Error('API key is required for Azure Anthropic.')
}
// Strip the azure-anthropic/ prefix from the model name if present
const modelName = request.model.replace(/^azure-anthropic\//, '')
// Azure AI Foundry hosts Anthropic models at {endpoint}/anthropic
// The SDK appends /v1/messages automatically
const baseURL = `${azureEndpoint.replace(/\/$/, '')}/anthropic`
const anthropicVersion =
request.azureApiVersion || env.AZURE_ANTHROPIC_API_VERSION || '2023-06-01'
return executeAnthropicProviderRequest(
{
...request,
model: modelName,
apiKey,
},
{
providerId: 'azure-anthropic',
providerLabel: 'Azure Anthropic',
createClient: (apiKey, useNativeStructuredOutputs) => {
const cacheKey = [
'azure-anthropic',
apiKey,
baseURL,
anthropicVersion,
pinnedIP ?? 'no-pin',
useNativeStructuredOutputs ? 'beta' : 'default',
].join('::')
return getCachedProviderClient(
cacheKey,
() =>
new Anthropic({
baseURL,
apiKey,
...(pinnedFetch ? { fetch: pinnedFetch } : {}),
defaultHeaders: {
'api-key': apiKey,
'anthropic-version': anthropicVersion,
...(useNativeStructuredOutputs
? { 'anthropic-beta': 'structured-outputs-2025-11-13' }
: {}),
},
})
)
},
logger,
}
)
},
}
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProviderRequest } from '@/providers/types'
const {
mockAzureOpenAI,
azureOpenAIArgs,
mockChatCreate,
mockValidate,
mockCreatePinnedFetch,
mockExecuteResponses,
sentinelFetch,
mockIsChatCompletionsEndpoint,
mockIsResponsesEndpoint,
envState,
} = vi.hoisted(() => {
const azureOpenAIArgs: Array<Record<string, unknown>> = []
const sentinelFetch = vi.fn()
const mockChatCreate = vi.fn()
class MockAzureOpenAI {
chat = { completions: { create: mockChatCreate } }
constructor(opts: Record<string, unknown>) {
azureOpenAIArgs.push(opts)
}
}
return {
mockAzureOpenAI: MockAzureOpenAI,
azureOpenAIArgs,
mockChatCreate,
mockValidate: vi.fn(),
mockCreatePinnedFetch: vi.fn(() => sentinelFetch),
mockExecuteResponses: vi.fn(),
sentinelFetch,
mockIsChatCompletionsEndpoint: vi.fn(() => false),
mockIsResponsesEndpoint: vi.fn(() => false),
envState: {
AZURE_OPENAI_ENDPOINT: undefined as string | undefined,
AZURE_OPENAI_API_VERSION: undefined as string | undefined,
},
}
})
vi.mock('openai', () => ({ AzureOpenAI: mockAzureOpenAI }))
vi.mock('@/lib/core/config/env', () => ({ env: envState }))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 }))
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: mockValidate,
createPinnedFetch: mockCreatePinnedFetch,
}))
vi.mock('@/providers/openai/core', () => ({
executeResponsesProviderRequest: mockExecuteResponses,
}))
vi.mock('@/providers/azure-openai/utils', () => ({
isChatCompletionsEndpoint: mockIsChatCompletionsEndpoint,
isResponsesEndpoint: mockIsResponsesEndpoint,
extractBaseUrl: vi.fn((url: string) => url),
extractDeploymentFromUrl: vi.fn(() => null),
extractApiVersionFromUrl: vi.fn(() => null),
createReadableStreamFromAzureOpenAIStream: vi.fn(),
checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })),
}))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn(() => []),
getProviderDefaultModel: vi.fn(() => 'azure/gpt-4o'),
}))
vi.mock('@/providers/attachments', () => ({
prepareProviderAttachments: vi.fn(() => []),
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })),
prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })),
prepareToolsWithUsageControl: vi.fn(() => ({
tools: [],
toolChoice: undefined,
forcedTools: [],
})),
sumToolCosts: vi.fn(() => 0),
}))
vi.mock('@/tools', () => ({ executeTool: vi.fn() }))
import { azureOpenAIProvider } from '@/providers/azure-openai/index'
function request(overrides: Partial<ProviderRequest>): ProviderRequest {
return { model: 'azure/gpt-4o', apiKey: 'k', messages: [], ...overrides }
}
/** Config object passed to the Responses core on the Nth call. */
const responsesConfig = (call = 0) => mockExecuteResponses.mock.calls[call][1]
describe('azureOpenAIProvider — SSRF pinning', () => {
beforeEach(() => {
vi.clearAllMocks()
azureOpenAIArgs.length = 0
envState.AZURE_OPENAI_ENDPOINT = undefined
envState.AZURE_OPENAI_API_VERSION = undefined
mockIsChatCompletionsEndpoint.mockReturnValue(false)
mockIsResponsesEndpoint.mockReturnValue(false)
mockExecuteResponses.mockResolvedValue({ content: 'ok' })
})
describe('Responses API path', () => {
it('validates and threads the pinned fetch into the Responses core for a user endpoint', async () => {
mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
await azureOpenAIProvider.executeRequest(
request({ azureEndpoint: 'https://rebind.attacker.tld' })
)
expect(mockValidate).toHaveBeenCalledWith('https://rebind.attacker.tld', 'azureEndpoint')
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
expect(responsesConfig().fetch).toBe(sentinelFetch)
})
it('passes no custom fetch when the endpoint comes from trusted server env', async () => {
envState.AZURE_OPENAI_ENDPOINT = 'https://trusted.openai.azure.com'
await azureOpenAIProvider.executeRequest(request({ azureEndpoint: undefined }))
expect(mockValidate).not.toHaveBeenCalled()
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(responsesConfig().fetch).toBeUndefined()
})
it('throws and never reaches the Responses core when validation blocks the endpoint', async () => {
mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' })
await expect(
azureOpenAIProvider.executeRequest(
request({ azureEndpoint: 'https://rebind.attacker.tld' })
)
).rejects.toThrow('Invalid Azure OpenAI endpoint')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockExecuteResponses).not.toHaveBeenCalled()
})
it('fails closed when validation passes but yields no resolvable IP to pin', async () => {
mockValidate.mockResolvedValue({ isValid: true })
await expect(
azureOpenAIProvider.executeRequest(
request({ azureEndpoint: 'https://rebind.attacker.tld' })
)
).rejects.toThrow('could not resolve a pinnable IP address')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(mockExecuteResponses).not.toHaveBeenCalled()
})
})
describe('Chat Completions path', () => {
it('constructs the AzureOpenAI client with the pinned fetch for a user endpoint', async () => {
mockIsChatCompletionsEndpoint.mockReturnValue(true)
mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
mockChatCreate.mockResolvedValue({
choices: [{ message: { content: 'hi', tool_calls: undefined } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
await azureOpenAIProvider.executeRequest(
request({
azureEndpoint: 'https://rebind.attacker.tld/openai/deployments/gpt-4o/chat/completions',
})
)
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
expect(azureOpenAIArgs[0]).toMatchObject({ fetch: sentinelFetch })
})
it('constructs the AzureOpenAI client without a custom fetch for a trusted env endpoint', async () => {
mockIsChatCompletionsEndpoint.mockReturnValue(true)
envState.AZURE_OPENAI_ENDPOINT =
'https://trusted.openai.azure.com/openai/deployments/gpt-4o/chat/completions'
mockChatCreate.mockResolvedValue({
choices: [{ message: { content: 'hi', tool_calls: undefined } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
await azureOpenAIProvider.executeRequest(request({ azureEndpoint: undefined }))
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(azureOpenAIArgs[0]).not.toHaveProperty('fetch')
})
})
})
+716
View File
@@ -0,0 +1,716 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { AzureOpenAI } from 'openai'
import type {
ChatCompletion,
ChatCompletionContentPart,
ChatCompletionCreateParamsBase,
ChatCompletionCreateParamsStreaming,
ChatCompletionMessageParam,
ChatCompletionTool,
ChatCompletionToolChoiceOption,
} from 'openai/resources/chat/completions'
import type { ReasoningEffort } from 'openai/resources/shared'
import { env } from '@/lib/core/config/env'
import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { prepareProviderAttachments } from '@/providers/attachments'
import {
checkForForcedToolUsage,
createReadableStreamFromAzureOpenAIStream,
extractApiVersionFromUrl,
extractBaseUrl,
extractDeploymentFromUrl,
isChatCompletionsEndpoint,
isResponsesEndpoint,
} 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,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('AzureOpenAIProvider')
/**
* Executes a request using the chat completions API.
* Used when the endpoint URL indicates chat completions.
*/
async function executeChatCompletionsRequest(
request: ProviderRequest,
azureEndpoint: string,
azureApiVersion: string,
deploymentName: string,
pinnedFetch?: typeof fetch
): Promise<ProviderResponse | StreamingExecution> {
logger.info('Using Azure OpenAI Chat Completions API', {
model: request.model,
endpoint: azureEndpoint,
deploymentName,
apiVersion: azureApiVersion,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const azureOpenAI = new AzureOpenAI({
apiKey: request.apiKey!,
apiVersion: azureApiVersion,
endpoint: azureEndpoint,
...(pinnedFetch ? { fetch: pinnedFetch } : {}),
})
const allMessages: ChatCompletionMessageParam[] = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
for (const message of request.messages) {
if (!message.files?.length || message.role !== 'user') {
allMessages.push(message as ChatCompletionMessageParam)
continue
}
const attachments = prepareProviderAttachments(message.files, 'azure-openai')
const nonImage = attachments.find((a) => a.contentType !== 'image')
if (nonImage) {
throw new Error(
`File "${nonImage.filename}" (${nonImage.mimeType}) requires the Azure OpenAI Responses API endpoint; chat-completions deployments support images only`
)
}
const parts: ChatCompletionContentPart[] = []
if (message.content) parts.push({ type: 'text', text: message.content })
for (const a of attachments) {
parts.push({ type: 'image_url', image_url: { url: a.remoteUrl ?? a.dataUrl ?? '' } })
}
const { files: _files, ...rest } = message
allMessages.push({ ...rest, content: parts } as ChatCompletionMessageParam)
}
}
const tools: ChatCompletionTool[] | undefined = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: ChatCompletionCreateParamsBase & { verbosity?: string } = {
model: deploymentName,
messages: allMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto')
payload.reasoning_effort = request.reasoningEffort as ReasoningEffort
if (request.verbosity !== undefined && request.verbosity !== 'auto')
payload.verbosity = request.verbosity
if (request.responseFormat) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
logger.info('Added JSON schema response format to Azure OpenAI request')
}
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'azure-openai')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools as ChatCompletionTool[]
payload.tool_choice = toolChoice as ChatCompletionToolChoiceOption
logger.info('Azure OpenAI request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: toolChoice.type === 'tool'
? `force:${toolChoice.name}`
: toolChoice.type === 'any'
? `force:${toolChoice.any?.name || 'unknown'}`
: 'unknown',
model: deploymentName,
})
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.stream && (!tools || tools.length === 0)) {
logger.info('Using streaming response for Azure OpenAI request')
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await azureOpenAI.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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,
}
const costResult = calculateCost(
request.model,
usage.prompt_tokens,
usage.completion_tokens
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = (await azureOpenAI.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)) as ChatCompletion
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls: FunctionCallResponse[] = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...allMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'azure_openai' }
)
const firstCheckResult = checkForForcedToolUsage(
currentResponse,
originalToolChoice ?? 'auto',
logger,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = firstCheckResult.hasUsedForcedTool
usedForcedTools = firstCheckResult.usedForcedTools
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
logger.info(
`Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_TOOL_ITERATIONS})`
)
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
})
let resultContent: Record<string, unknown>
if (result.success) {
toolResults.push(result.output as Record<string, unknown>)
resultContent = result.output as Record<string, unknown>
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = (await azureOpenAI.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)) as ChatCompletion
const nextCheckResult = checkForForcedToolUsage(
currentResponse,
nextPayload.tool_choice ?? 'auto',
logger,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = nextCheckResult.hasUsedForcedTool
usedForcedTools = nextCheckResult.usedForcedTools
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'azure_openai' }
)
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (request.stream) {
logger.info('Using streaming for final response after tool processing')
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await azureOpenAI.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 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,
}
finalizeTiming()
}),
})
return streamingResult
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Azure OpenAI chat completions request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
}
/**
* Azure OpenAI provider configuration
*/
export const azureOpenAIProvider: ProviderConfig = {
id: 'azure-openai',
name: 'Azure OpenAI',
description: 'Microsoft Azure OpenAI Service models',
version: '1.0.0',
models: getProviderModels('azure-openai'),
defaultModel: getProviderDefaultModel('azure-openai'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
const userProvidedEndpoint = request.azureEndpoint
const azureEndpoint = userProvidedEndpoint || env.AZURE_OPENAI_ENDPOINT
if (!azureEndpoint) {
throw new Error(
'Azure OpenAI endpoint is required. Please provide it via azureEndpoint parameter or AZURE_OPENAI_ENDPOINT environment variable.'
)
}
let pinnedFetch: typeof fetch | undefined
if (userProvidedEndpoint) {
const validation = await validateUrlWithDNS(userProvidedEndpoint, 'azureEndpoint')
if (!validation.isValid) {
logger.warn('Blocked SSRF attempt via azureEndpoint', {
endpoint: userProvidedEndpoint,
error: validation.error,
})
throw new Error(`Invalid Azure OpenAI endpoint: ${validation.error}`)
}
if (!validation.resolvedIP) {
throw new Error('Invalid Azure OpenAI endpoint: could not resolve a pinnable IP address')
}
pinnedFetch = createPinnedFetch(validation.resolvedIP)
}
const apiKey = request.apiKey
if (!apiKey) {
throw new Error('API key is required for Azure OpenAI.')
}
// Check if the endpoint is a full chat completions URL
if (isChatCompletionsEndpoint(azureEndpoint)) {
logger.info('Detected chat completions endpoint URL')
// Extract the base URL for the SDK (it needs just the host, not the full path)
const baseUrl = extractBaseUrl(azureEndpoint)
// Try to extract deployment from URL, fall back to model name
const urlDeployment = extractDeploymentFromUrl(azureEndpoint)
const deploymentName = urlDeployment || request.model.replace('azure/', '')
// Try to extract api-version from URL, fall back to request param or env or default
const urlApiVersion = extractApiVersionFromUrl(azureEndpoint)
const azureApiVersion =
urlApiVersion ||
request.azureApiVersion ||
env.AZURE_OPENAI_API_VERSION ||
'2024-07-01-preview'
logger.info('Chat completions configuration:', {
originalEndpoint: azureEndpoint,
baseUrl,
deploymentName,
apiVersion: azureApiVersion,
})
return executeChatCompletionsRequest(
{ ...request, apiKey },
baseUrl,
azureApiVersion,
deploymentName,
pinnedFetch
)
}
// Check if the endpoint is already a full responses API URL
if (isResponsesEndpoint(azureEndpoint)) {
logger.info('Detected full responses endpoint URL, using it directly')
const deploymentName = request.model.replace('azure/', '')
// Use the URL as-is since it's already complete
return executeResponsesProviderRequest(
{ ...request, apiKey },
{
providerId: 'azure-openai',
providerLabel: 'Azure OpenAI',
modelName: deploymentName,
endpoint: azureEndpoint,
headers: {
'Content-Type': 'application/json',
'OpenAI-Beta': 'responses=v1',
'api-key': apiKey,
},
logger,
fetch: pinnedFetch,
}
)
}
// Default: base URL provided, construct the responses API URL
logger.info('Using base endpoint, constructing Responses API URL')
const azureApiVersion =
request.azureApiVersion || env.AZURE_OPENAI_API_VERSION || '2024-07-01-preview'
const deploymentName = request.model.replace('azure/', '')
const apiUrl = `${azureEndpoint.replace(/\/$/, '')}/openai/v1/responses?api-version=${azureApiVersion}`
return executeResponsesProviderRequest(
{ ...request, apiKey },
{
providerId: 'azure-openai',
providerLabel: 'Azure OpenAI',
modelName: deploymentName,
endpoint: apiUrl,
headers: {
'Content-Type': 'application/json',
'OpenAI-Beta': 'responses=v1',
'api-key': apiKey,
},
logger,
fetch: pinnedFetch,
}
)
},
}
+119
View File
@@ -0,0 +1,119 @@
import type { Logger } from '@sim/logger'
import type OpenAI from 'openai'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import type { Stream } from 'openai/streaming'
import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from an Azure OpenAI streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromAzureOpenAIStream(
azureOpenAIStream: Stream<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream {
return createOpenAICompatibleStream(azureOpenAIStream, 'Azure OpenAI', onComplete)
}
/**
* Checks if a forced tool was used in an Azure OpenAI response.
* Uses the shared OpenAI-compatible forced tool usage helper.
*/
export function checkForForcedToolUsage(
response: OpenAI.Chat.Completions.ChatCompletion,
toolChoice: string | { type: string; function?: { name: string }; name?: string },
_logger: Logger,
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } {
return checkForForcedToolUsageOpenAI(
response,
toolChoice,
'Azure OpenAI',
forcedTools,
usedForcedTools,
_logger
)
}
/**
* Determines if an Azure OpenAI endpoint URL is for the chat completions API.
* Returns true for URLs containing /chat/completions pattern.
*
* @param endpoint - The Azure OpenAI endpoint URL
* @returns true if the endpoint is for chat completions API
*/
export function isChatCompletionsEndpoint(endpoint: string): boolean {
const normalizedEndpoint = endpoint.toLowerCase()
return normalizedEndpoint.includes('/chat/completions')
}
/**
* Determines if an Azure OpenAI endpoint URL is already a complete responses API URL.
* Returns true for URLs containing /responses pattern (but not /chat/completions).
*
* @param endpoint - The Azure OpenAI endpoint URL
* @returns true if the endpoint is already a responses API URL
*/
export function isResponsesEndpoint(endpoint: string): boolean {
const normalizedEndpoint = endpoint.toLowerCase()
return (
normalizedEndpoint.includes('/responses') && !normalizedEndpoint.includes('/chat/completions')
)
}
/**
* Extracts the base URL from a full Azure OpenAI chat completions URL.
* For example:
* Input: https://resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-01-01
* Output: https://resource.openai.azure.com
*
* @param fullUrl - The full chat completions URL
* @returns The base URL (scheme + host)
*/
export function extractBaseUrl(fullUrl: string): string {
try {
const url = new URL(fullUrl)
return `${url.protocol}//${url.host}`
} catch {
// If parsing fails, try to extract up to .com or .azure.com
const match = fullUrl.match(/^(https?:\/\/[^/]+)/)
return match ? match[1] : fullUrl
}
}
/**
* Extracts the deployment name from a full Azure OpenAI URL.
* For example:
* Input: https://resource.openai.azure.com/openai/deployments/gpt-4.1-mini/chat/completions?api-version=2024-01-01
* Output: gpt-4.1-mini
*
* @param fullUrl - The full Azure OpenAI URL
* @returns The deployment name or null if not found
*/
export function extractDeploymentFromUrl(fullUrl: string): string | null {
// Match /deployments/{deployment-name}/ pattern
const match = fullUrl.match(/\/deployments\/([^/]+)/i)
return match ? match[1] : null
}
/**
* Extracts the api-version from a full Azure OpenAI URL query string.
* For example:
* Input: https://resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2025-01-01-preview
* Output: 2025-01-01-preview
*
* @param fullUrl - The full Azure OpenAI URL
* @returns The api-version or null if not found
*/
export function extractApiVersionFromUrl(fullUrl: string): string | null {
try {
const url = new URL(fullUrl)
return url.searchParams.get('api-version')
} catch {
// Fallback regex for malformed URLs
const match = fullUrl.match(/[?&]api-version=([^&]+)/i)
return match ? match[1] : null
}
}
+238
View File
@@ -0,0 +1,238 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCreate,
mockSupportsNativeStructuredOutputs,
mockPrepareToolsWithUsageControl,
mockExecuteTool,
} = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockSupportsNativeStructuredOutputs: vi.fn(),
mockPrepareToolsWithUsageControl: vi.fn(),
mockExecuteTool: vi.fn(),
}))
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(
class {
chat = { completions: { create: mockCreate } }
}
),
}))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn().mockReturnValue([]),
getProviderDefaultModel: vi.fn().mockReturnValue('openai/gpt-oss-120b'),
}))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: vi.fn((messages) => messages),
}))
vi.mock('@/providers/baseten/utils', () => ({
supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs,
createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream),
checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })),
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }),
generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'),
prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })),
prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl,
sumToolCosts: vi.fn().mockReturnValue(0),
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
import { basetenProvider } from '@/providers/baseten/index'
import { ProviderError } from '@/providers/types'
const textResponse = (content: string) => ({
choices: [{ message: { content, tool_calls: [] } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
})
const toolCallResponse = () => ({
choices: [
{
message: {
content: null,
tool_calls: [
{ id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } },
],
},
},
],
usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 },
})
const toolDef = {
id: 'my_tool',
name: 'my_tool',
description: '',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
}
const callBody = (index: number) => mockCreate.mock.calls[index][0]
const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0]
describe('basetenProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSupportsNativeStructuredOutputs.mockResolvedValue(true)
mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({
tools,
toolChoice: 'auto',
forcedTools: [],
}))
mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } })
})
const baseRequest = {
model: 'baseten/openai/gpt-oss-120b',
systemPrompt: 'You are helpful.',
messages: [{ role: 'user' as const, content: 'Hello' }],
apiKey: 'bt-test-key',
}
it('throws when the API key is missing', async () => {
await expect(
basetenProvider.executeRequest({ ...baseRequest, apiKey: undefined })
).rejects.toThrow('API key is required for Baseten')
})
it('returns content and token usage for a simple request', async () => {
mockCreate.mockResolvedValueOnce(textResponse('hi there'))
const result = await basetenProvider.executeRequest(baseRequest)
expect(result).toMatchObject({
content: 'hi there',
model: 'openai/gpt-oss-120b',
tokens: { input: 10, output: 5, total: 15 },
})
})
it('strips only the leading baseten/ prefix from the model id', async () => {
mockCreate.mockResolvedValueOnce(textResponse('ok'))
await basetenProvider.executeRequest(baseRequest)
expect(callBody(0).model).toBe('openai/gpt-oss-120b')
})
it('wraps API errors in a ProviderError', async () => {
mockCreate.mockRejectedValueOnce(new Error('boom'))
await expect(basetenProvider.executeRequest(baseRequest)).rejects.toBeInstanceOf(ProviderError)
})
it('streams directly when there are no tools', async () => {
mockCreate.mockResolvedValueOnce({})
const result = await basetenProvider.executeRequest({ ...baseRequest, stream: true })
expect(lastCallBody()).toMatchObject({ stream: true, stream_options: { include_usage: true } })
expect(result).toHaveProperty('stream')
expect(result).toHaveProperty('execution')
})
it('sends a json_schema response_format with no strict field', async () => {
mockCreate.mockResolvedValueOnce(textResponse('{}'))
await basetenProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' }, strict: true },
})
expect(lastCallBody().response_format).toEqual({
type: 'json_schema',
json_schema: { name: 'my_schema', schema: { type: 'object' } },
})
expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict')
})
it('falls back to json_object with prompt instructions when native is unsupported', async () => {
mockSupportsNativeStructuredOutputs.mockResolvedValue(false)
mockCreate.mockResolvedValueOnce(textResponse('{}'))
await basetenProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' } },
})
expect(lastCallBody().response_format).toEqual({ type: 'json_object' })
expect(lastCallBody().messages.at(-1)).toEqual({
role: 'user',
content: 'SCHEMA_INSTRUCTIONS',
})
})
it('defers response_format to a final call when tools are active', async () => {
mockCreate
.mockResolvedValueOnce(textResponse('intermediate'))
.mockResolvedValueOnce(textResponse('{"done":true}'))
await basetenProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' } },
tools: [toolDef],
})
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(callBody(0).response_format).toBeUndefined()
expect(callBody(0).tools).toBeDefined()
expect(callBody(1).response_format).toEqual({
type: 'json_schema',
json_schema: { name: 'my_schema', schema: { type: 'object' } },
})
expect(callBody(1).tools).toBeUndefined()
})
it('runs the tool loop and threads tool results back into the conversation', async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse())
.mockResolvedValueOnce(textResponse('final answer'))
const result = await basetenProvider.executeRequest({ ...baseRequest, tools: [toolDef] })
expect(mockExecuteTool).toHaveBeenCalledWith('my_tool', { x: 1 }, expect.anything())
expect(result).toMatchObject({ content: 'final answer' })
expect((result as { toolCalls?: unknown[] }).toolCalls).toHaveLength(1)
const followUpMessages = callBody(1).messages
expect(followUpMessages).toContainEqual(
expect.objectContaining({ role: 'assistant', tool_calls: expect.any(Array) })
)
expect(followUpMessages).toContainEqual(
expect.objectContaining({ role: 'tool', tool_call_id: 'call_1' })
)
})
it("forces tool_choice 'none' on the final streaming call after tools run", async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse())
.mockResolvedValueOnce(textResponse('done'))
.mockResolvedValueOnce({})
await basetenProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] })
expect(mockCreate).toHaveBeenCalledTimes(3)
expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true })
})
})
+599
View File
@@ -0,0 +1,599 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import {
checkForForcedToolUsage,
createReadableStreamFromOpenAIStream,
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,
Message,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
generateSchemaInstructions,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('BasetenProvider')
/**
* Applies structured output configuration to a payload based on model capabilities.
* Uses native json_schema for supported models, falls back to json_object with prompt instructions.
*/
async function applyResponseFormat(
targetPayload: any,
messages: any[],
responseFormat: any,
model: string
): Promise<any[]> {
const useNative = await supportsNativeStructuredOutputs(model)
if (useNative) {
logger.info('Using native structured outputs for Baseten model', { model })
targetPayload.response_format = {
type: 'json_schema',
json_schema: {
name: responseFormat.name || 'response_schema',
schema: responseFormat.schema || responseFormat,
},
}
return messages
}
logger.info('Using json_object mode with prompt instructions for Baseten model', { model })
const schema = responseFormat.schema || responseFormat
const schemaInstructions = generateSchemaInstructions(schema, responseFormat.name)
targetPayload.response_format = { type: 'json_object' }
return [...messages, { role: 'user', content: schemaInstructions }]
}
export const basetenProvider: ProviderConfig = {
id: 'baseten',
name: 'Baseten',
description: 'Fast inference for open-source models via Baseten Model APIs',
version: '1.0.0',
models: getProviderModels('baseten'),
defaultModel: getProviderDefaultModel('baseten'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Baseten')
}
const client = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://inference.baseten.co/v1',
})
const requestedModel = request.model.replace(/^baseten\//, '')
logger.info('Preparing Baseten request', {
model: requestedModel,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({ role: 'system', content: request.systemPrompt })
}
if (request.context) {
allMessages.push({ role: 'user', content: request.context })
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'baseten') as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: requestedModel,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'baseten')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.responseFormat && !hasActiveTools) {
payload.messages = await applyResponseFormat(
payload,
payload.messages,
request.responseFormat,
requestedModel
)
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 costResult = calculateCost(
requestedModel,
usage.prompt_tokens,
usage.completion_tokens
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await client.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls: FunctionCallResponse[] = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
const forcedToolResult = checkForForcedToolUsage(
currentResponse,
originalToolChoice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = forcedToolResult.hasUsedForcedTool
usedForcedTools = forcedToolResult.usedForcedTools
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'baseten' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call (Baseten):', {
error: toError(error).message,
toolName,
})
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success) {
toolResults.push(result.output!)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } }
} else {
nextPayload.tool_choice = 'auto'
}
}
const nextModelStartTime = Date.now()
currentResponse = await client.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const nextForcedToolResult = checkForForcedToolUsage(
currentResponse,
nextPayload.tool_choice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool
usedForcedTools = nextForcedToolResult.usedForcedTools
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'baseten' }
)
}
if (request.stream) {
const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: [...currentMessages],
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
if (request.responseFormat) {
;(streamingParams as any).messages = await applyResponseFormat(
streamingParams as any,
streamingParams.messages,
request.responseFormat,
requestedModel
)
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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,
},
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
}
if (request.responseFormat && hasActiveTools) {
const finalPayload: any = {
model: payload.model,
messages: [...currentMessages],
}
if (payload.temperature !== undefined) {
finalPayload.temperature = payload.temperature
}
if (payload.max_tokens !== undefined) {
finalPayload.max_tokens = payload.max_tokens
}
finalPayload.messages = await applyResponseFormat(
finalPayload,
finalPayload.messages,
request.responseFormat,
requestedModel
)
const finalStartTime = Date.now()
const finalResponse = await client.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalEndTime = Date.now()
const finalDuration = finalEndTime - finalStartTime
timeSegments.push({
type: 'model',
name: 'Final structured response',
startTime: finalStartTime,
endTime: finalEndTime,
duration: finalDuration,
})
modelTime += finalDuration
if (finalResponse.choices[0]?.message?.content) {
content = finalResponse.choices[0].message.content
}
if (finalResponse.usage) {
tokens.input += finalResponse.usage.prompt_tokens || 0
tokens.output += finalResponse.usage.completion_tokens || 0
tokens.total += finalResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
finalResponse,
finalResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'baseten' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: requestedModel,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
const errorDetails: Record<string, any> = {
error: toError(error).message,
duration: totalDuration,
}
if (error && typeof error === 'object') {
const err = error as any
if (err.status) errorDetails.status = err.status
if (err.code) errorDetails.code = err.code
if (err.type) errorDetails.type = err.type
if (err.error?.message) errorDetails.providerMessage = err.error.message
if (err.error?.metadata) errorDetails.metadata = err.error.metadata
}
logger.error('Error in Baseten request:', errorDetails)
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+41
View File
@@ -0,0 +1,41 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils'
/**
* Checks if a model supports native structured outputs (json_schema).
* Baseten Model APIs support structured outputs across their OpenAI-compatible inference API.
*/
export async function supportsNativeStructuredOutputs(_modelId: string): Promise<boolean> {
return true
}
/**
* Creates a ReadableStream from a Baseten streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromOpenAIStream(
openaiStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(openaiStream, 'Baseten', onComplete)
}
/**
* Checks if a forced tool was used in a Baseten response.
* Uses the shared OpenAI-compatible forced tool usage helper.
*/
export function checkForForcedToolUsage(
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } {
return checkForForcedToolUsageOpenAI(
response,
toolChoice,
'Baseten',
forcedTools,
usedForcedTools
)
}
+123
View File
@@ -0,0 +1,123 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockSend = vi.fn()
vi.mock('@aws-sdk/client-bedrock-runtime', () => ({
BedrockRuntimeClient: vi.fn().mockImplementation(
class {
send = mockSend
}
),
ConverseCommand: vi.fn(),
ConverseStreamCommand: vi.fn(),
}))
vi.mock('@/providers/bedrock/utils', () => ({
getBedrockInferenceProfileId: vi
.fn()
.mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'),
checkForForcedToolUsage: vi.fn(),
createReadableStreamFromBedrockStream: vi.fn(),
generateToolUseId: vi.fn().mockReturnValue('tool-1'),
}))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn().mockReturnValue([]),
getProviderDefaultModel: vi.fn().mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0, pricing: null }),
prepareToolExecution: vi.fn(),
prepareToolsWithUsageControl: vi.fn().mockReturnValue({
tools: [],
toolChoice: 'auto',
forcedTools: [],
}),
sumToolCosts: vi.fn().mockReturnValue(0),
}))
vi.mock('@/tools', () => ({
executeTool: vi.fn(),
}))
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'
import { bedrockProvider } from '@/providers/bedrock/index'
import { clearProviderClientCacheForTests } from '@/providers/client-cache'
describe('bedrockProvider credential handling', () => {
beforeEach(() => {
vi.clearAllMocks()
clearProviderClientCacheForTests()
mockSend.mockResolvedValue({
output: { message: { content: [{ text: 'response' }] } },
usage: { inputTokens: 10, outputTokens: 5 },
})
})
const baseRequest = {
model: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
systemPrompt: 'You are helpful.',
messages: [{ role: 'user' as const, content: 'Hello' }],
}
it('throws when only bedrockAccessKeyId is provided', async () => {
await expect(
bedrockProvider.executeRequest({
...baseRequest,
bedrockAccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
})
).rejects.toThrow('Both bedrockAccessKeyId and bedrockSecretKey must be provided together')
})
it('throws when only bedrockSecretKey is provided', async () => {
await expect(
bedrockProvider.executeRequest({
...baseRequest,
bedrockSecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
})
).rejects.toThrow('Both bedrockAccessKeyId and bedrockSecretKey must be provided together')
})
it('creates client with explicit credentials when both are provided', async () => {
await bedrockProvider.executeRequest({
...baseRequest,
bedrockAccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
bedrockSecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
})
expect(BedrockRuntimeClient).toHaveBeenCalledWith({
region: 'us-east-1',
credentials: {
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
},
})
})
it('creates client without credentials when neither is provided', async () => {
await bedrockProvider.executeRequest(baseRequest)
expect(BedrockRuntimeClient).toHaveBeenCalledWith({
region: 'us-east-1',
})
})
it('uses custom region when provided', async () => {
await bedrockProvider.executeRequest({
...baseRequest,
bedrockRegion: 'eu-west-1',
})
expect(BedrockRuntimeClient).toHaveBeenCalledWith({
region: 'eu-west-1',
})
})
})
+956
View File
@@ -0,0 +1,956 @@
import {
type Message as BedrockMessage,
BedrockRuntimeClient,
type BedrockRuntimeClientConfig,
type ContentBlock,
type ConversationRole,
ConverseCommand,
type ConverseResponse,
ConverseStreamCommand,
type SystemContentBlock,
type Tool,
type ToolConfiguration,
type ToolResultBlock,
type ToolUseBlock,
} from '@aws-sdk/client-bedrock-runtime'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import type { IterationToolCall, StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { buildBedrockMessageContent } from '@/providers/attachments'
import {
checkForForcedToolUsage,
createReadableStreamFromBedrockStream,
generateToolUseId,
getBedrockInferenceProfileId,
} from '@/providers/bedrock/utils'
import { getCachedProviderClient } from '@/providers/client-cache'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { createStreamingExecution } from '@/providers/streaming-execution'
import { enrichLastModelSegment } from '@/providers/trace-enrichment'
import type {
FunctionCallResponse,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('BedrockProvider')
function enrichLastModelSegmentFromBedrockResponse(
timeSegments: TimeSegment[],
response: ConverseResponse,
extras: { model: string }
): void {
const blocks: ContentBlock[] = response.output?.message?.content ?? []
const assistantText = blocks
.filter((b): b is ContentBlock & { text: string } => 'text' in b && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
const assistantContent = assistantText.length > 0 ? assistantText : undefined
const toolCalls: IterationToolCall[] = blocks
.filter((b): b is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in b && !!b.toolUse)
.map((b) => {
const input = b.toolUse.input
return {
id: b.toolUse.toolUseId ?? '',
name: b.toolUse.name ?? '',
arguments:
input && typeof input === 'object' && !Array.isArray(input)
? (input as Record<string, unknown>)
: {},
}
})
const inputTokens = response.usage?.inputTokens
const outputTokens = response.usage?.outputTokens
let cost: { input: number; output: number; total: number } | undefined
if (typeof inputTokens === 'number' && typeof outputTokens === 'number') {
const full = calculateCost(extras.model, inputTokens, outputTokens)
cost = { input: full.input, output: full.output, total: full.total }
}
enrichLastModelSegment(timeSegments, {
assistantContent,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
finishReason: response.stopReason ?? undefined,
tokens:
inputTokens !== undefined || outputTokens !== undefined
? {
input: inputTokens,
output: outputTokens,
total:
typeof inputTokens === 'number' && typeof outputTokens === 'number'
? inputTokens + outputTokens
: undefined,
}
: undefined,
cost,
provider: 'aws.bedrock',
})
}
export const bedrockProvider: ProviderConfig = {
id: 'bedrock',
name: 'AWS Bedrock',
description: 'AWS Bedrock foundation models',
version: '1.0.0',
models: getProviderModels('bedrock'),
defaultModel: getProviderDefaultModel('bedrock'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
const region = request.bedrockRegion || 'us-east-1'
const bedrockModelId = getBedrockInferenceProfileId(request.model, region)
logger.info('Bedrock request', {
requestModel: request.model,
inferenceProfileId: bedrockModelId,
region,
})
const hasAccessKey = Boolean(request.bedrockAccessKeyId)
const hasSecretKey = Boolean(request.bedrockSecretKey)
if (hasAccessKey !== hasSecretKey) {
throw new Error(
'Both bedrockAccessKeyId and bedrockSecretKey must be provided together. ' +
'Provide both for explicit credentials, or omit both to use the AWS default credential chain.'
)
}
const clientConfig: BedrockRuntimeClientConfig = { region }
if (request.bedrockAccessKeyId && request.bedrockSecretKey) {
clientConfig.credentials = {
accessKeyId: request.bedrockAccessKeyId,
secretAccessKey: request.bedrockSecretKey,
}
}
// Key on the full credential (access key id + secret) so a corrected secret
// under the same access key id yields a fresh client rather than a stale one.
const credentialKey =
request.bedrockAccessKeyId && request.bedrockSecretKey
? `${request.bedrockAccessKeyId}:${request.bedrockSecretKey}`
: 'default-chain'
const client = getCachedProviderClient(
`bedrock::${region}::${credentialKey}`,
() => new BedrockRuntimeClient(clientConfig)
)
const messages: BedrockMessage[] = []
const systemContent: SystemContentBlock[] = []
if (request.systemPrompt) {
systemContent.push({ text: request.systemPrompt })
}
if (request.context) {
messages.push({
role: 'user' as ConversationRole,
content: [{ text: request.context }],
})
}
if (request.messages) {
for (const msg of request.messages) {
if (msg.role === 'function' || msg.role === 'tool') {
const toolResultBlock: ToolResultBlock = {
toolUseId: msg.tool_call_id || msg.name || generateToolUseId('tool'),
content: [{ text: msg.content || '' }],
}
messages.push({
role: 'user' as ConversationRole,
content: [{ toolResult: toolResultBlock }],
})
} else if (msg.function_call || msg.tool_calls) {
const toolCall = msg.function_call || msg.tool_calls?.[0]?.function
if (toolCall) {
const toolUseBlock: ToolUseBlock = {
toolUseId: msg.tool_calls?.[0]?.id || generateToolUseId(toolCall.name),
name: toolCall.name,
input: JSON.parse(toolCall.arguments),
}
messages.push({
role: 'assistant' as ConversationRole,
content: [{ toolUse: toolUseBlock }],
})
}
} else {
const role: ConversationRole = msg.role === 'assistant' ? 'assistant' : 'user'
const content = buildBedrockMessageContent(msg.content, msg.files, 'bedrock')
messages.push({
role,
// double-cast-allowed: shared attachment builder emits Bedrock Converse content blocks while keeping provider-neutral attachment types
content: content as unknown as ContentBlock[],
})
}
}
}
if (messages.length === 0) {
messages.push({
role: 'user' as ConversationRole,
content: [{ text: request.systemPrompt || 'Hello' }],
})
systemContent.length = 0
}
let structuredOutputTool: Tool | undefined
const structuredOutputToolName = 'structured_output'
if (request.responseFormat) {
const schema = request.responseFormat.schema || request.responseFormat
const schemaName = request.responseFormat.name || 'response'
structuredOutputTool = {
toolSpec: {
name: structuredOutputToolName,
description: `Output the response as structured JSON matching the ${schemaName} schema. You MUST call this tool to provide your final response.`,
inputSchema: {
json: schema,
},
},
}
logger.info(`Using Tool Use approach for structured outputs: ${schemaName}`)
}
let bedrockTools: Tool[] | undefined
let toolChoice: any = { auto: {} }
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
if (request.tools?.length) {
bedrockTools = request.tools.map((tool) => ({
toolSpec: {
name: tool.id,
description: tool.description,
inputSchema: {
json: {
type: 'object',
properties: tool.parameters.properties,
required: tool.parameters.required,
},
},
},
}))
try {
preparedTools = prepareToolsWithUsageControl(
bedrockTools.map((t) => ({
name: t.toolSpec?.name || '',
description: t.toolSpec?.description || '',
input_schema: t.toolSpec?.inputSchema?.json,
})),
request.tools,
logger,
'bedrock'
)
const { tools: filteredTools, toolChoice: tc } = preparedTools
if (filteredTools?.length) {
bedrockTools = filteredTools.map((t: any) => ({
toolSpec: {
name: t.name,
description: t.description,
inputSchema: { json: t.input_schema },
},
}))
if (typeof tc === 'object' && tc !== null) {
if (tc.type === 'tool' && tc.name) {
toolChoice = { tool: { name: tc.name } }
logger.info(`Using Bedrock tool_choice format: force tool "${tc.name}"`)
} else if (tc.type === 'function' && tc.function?.name) {
toolChoice = { tool: { name: tc.function.name } }
logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`)
} else if (tc.type === 'any') {
toolChoice = { any: {} }
logger.info('Using Bedrock tool_choice format: any tool')
} else {
toolChoice = { auto: {} }
}
} else if (tc === 'none') {
toolChoice = undefined
bedrockTools = undefined
} else {
toolChoice = { auto: {} }
}
}
} catch (error) {
logger.error('Error in prepareToolsWithUsageControl:', { error })
toolChoice = { auto: {} }
}
} else if (structuredOutputTool) {
bedrockTools = [structuredOutputTool]
toolChoice = { tool: { name: structuredOutputToolName } }
logger.info('Using structured_output tool as only tool (forced)')
}
const hasToolContentInMessages = messages.some((msg) =>
msg.content?.some(
(block) =>
('toolUse' in block && block.toolUse) || ('toolResult' in block && block.toolResult)
)
)
const toolConfig: ToolConfiguration | undefined = bedrockTools?.length
? {
tools: bedrockTools,
toolChoice,
}
: hasToolContentInMessages && request.tools?.length
? {
tools: request.tools.map((tool) => ({
toolSpec: {
name: tool.id,
description: tool.description,
inputSchema: {
json: {
type: 'object',
properties: tool.parameters.properties,
required: tool.parameters.required,
},
},
},
})),
toolChoice: { auto: {} },
}
: undefined
if (hasToolContentInMessages && !toolConfig) {
throw new Error(
'Messages contain tool use/result blocks but no tools were provided. ' +
'Bedrock requires toolConfig when processing messages with tool content.'
)
}
const systemPromptWithSchema = systemContent
const inferenceConfig: { temperature: number; maxTokens?: number } = {
temperature: Number.parseFloat(String(request.temperature ?? 0.7)),
}
if (request.maxTokens != null) {
inferenceConfig.maxTokens = Number.parseInt(String(request.maxTokens))
}
const shouldStreamToolCalls = request.streamToolCalls ?? false
if (request.stream && (!bedrockTools || bedrockTools.length === 0)) {
logger.info('Using streaming response for Bedrock request (no tools)')
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
const command = new ConverseStreamCommand({
modelId: bedrockModelId,
messages,
system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined,
inferenceConfig,
})
const streamResponse = await client.send(
command,
request.abortSignal ? { abortSignal: request.abortSignal } : undefined
)
if (!streamResponse.stream) {
throw new Error('No stream returned from Bedrock')
}
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,
}
const costResult = calculateCost(request.model, usage.inputTokens, usage.outputTokens)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const initialCallTime = Date.now()
const originalToolChoice = toolChoice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
const command = new ConverseCommand({
modelId: bedrockModelId,
messages,
system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined,
inferenceConfig,
toolConfig,
})
let currentResponse = await client.send(
command,
request.abortSignal ? { abortSignal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = ''
let hasExtractedStructuredOutput = false
if (currentResponse.output?.message?.content) {
const structuredOutputCall = currentResponse.output.message.content.find(
(block): block is ContentBlock & { toolUse: ToolUseBlock } =>
'toolUse' in block && block.toolUse?.name === structuredOutputToolName
)
if (structuredOutputCall && structuredOutputTool) {
content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2)
hasExtractedStructuredOutput = true
logger.info('Extracted structured output from tool call')
} else {
const textBlocks = currentResponse.output.message.content.filter(
(block): block is ContentBlock & { text: string } => 'text' in block
)
content = textBlocks.map((block) => block.text).join('\n')
}
}
const tokens = {
input: currentResponse.usage?.inputTokens || 0,
output: currentResponse.usage?.outputTokens || 0,
total:
(currentResponse.usage?.inputTokens || 0) + (currentResponse.usage?.outputTokens || 0),
}
const initialCost = calculateCost(
request.model,
currentResponse.usage?.inputTokens || 0,
currentResponse.usage?.outputTokens || 0
)
const cost = {
input: initialCost.input,
output: initialCost.output,
total: initialCost.total,
pricing: initialCost.pricing,
}
const toolCalls: FunctionCallResponse[] = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...messages]
let iterationCount = 0
let hasUsedForcedTool = false
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
enrichLastModelSegmentFromBedrockResponse(timeSegments, currentResponse, {
model: request.model,
})
const initialToolUseContentBlocks = (currentResponse.output?.message?.content || []).filter(
(block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block
)
const toolUseBlocks = initialToolUseContentBlocks.map((block) => ({
name: block.toolUse.name || '',
}))
const firstCheckResult = checkForForcedToolUsage(
toolUseBlocks,
originalToolChoice,
forcedTools,
usedForcedTools
)
if (firstCheckResult) {
hasUsedForcedTool = firstCheckResult.hasUsedForcedTool
usedForcedTools = firstCheckResult.usedForcedTools
}
while (iterationCount < MAX_TOOL_ITERATIONS) {
const textContentBlocks = (currentResponse.output?.message?.content || []).filter(
(block): block is ContentBlock & { text: string } => 'text' in block
)
const textContent = textContentBlocks.map((block) => block.text).join('\n')
if (textContent) {
content = textContent
}
const toolUseContentBlocks = (currentResponse.output?.message?.content || []).filter(
(block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block
)
const currentToolUses = toolUseContentBlocks.map((block) => block.toolUse)
if (!currentToolUses || currentToolUses.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = currentToolUses.map(async (toolUse: ToolUseBlock) => {
const toolCallStartTime = Date.now()
const toolName = toolUse.name || ''
const toolArgs = (toolUse.input as Record<string, any>) || {}
const toolUseId = toolUse.toolUseId || generateToolUseId(toolName)
try {
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolUseId,
toolName,
toolArgs,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolUseId,
toolName,
toolArgs,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
const assistantContent: ContentBlock[] = currentToolUses.map((toolUse: ToolUseBlock) => ({
toolUse: {
toolUseId: toolUse.toolUseId,
name: toolUse.name,
input: toolUse.input,
},
}))
currentMessages.push({
role: 'assistant' as ConversationRole,
content: assistantContent,
})
const toolResultContent: ContentBlock[] = []
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const {
toolUseId,
toolName,
toolArgs,
toolParams,
result,
startTime,
endTime,
duration,
} = settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime,
endTime,
duration,
})
let resultContent: any
if (result.success) {
toolResults.push(result.output!)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration,
result: resultContent,
success: result.success,
})
const toolResultBlock: ToolResultBlock = {
toolUseId,
content: [{ text: JSON.stringify(resultContent) }],
}
toolResultContent.push({ toolResult: toolResultBlock })
}
if (toolResultContent.length > 0) {
currentMessages.push({
role: 'user' as ConversationRole,
content: toolResultContent,
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
let nextToolChoice = toolChoice
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextToolChoice = { tool: { name: remainingTools[0] } }
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextToolChoice = { auto: {} }
logger.info('All forced tools have been used, switching to auto')
}
} else if (hasUsedForcedTool && typeof originalToolChoice === 'object') {
nextToolChoice = { auto: {} }
logger.info('Switching to auto tool choice after forced tool was used')
}
const nextModelStartTime = Date.now()
const nextCommand = new ConverseCommand({
modelId: bedrockModelId,
messages: currentMessages,
system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined,
inferenceConfig,
toolConfig: bedrockTools?.length
? { tools: bedrockTools, toolChoice: nextToolChoice }
: undefined,
})
currentResponse = await client.send(
nextCommand,
request.abortSignal ? { abortSignal: request.abortSignal } : undefined
)
const nextToolUseContentBlocks = (currentResponse.output?.message?.content || []).filter(
(block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block
)
const nextToolUseBlocks = nextToolUseContentBlocks.map((block) => ({
name: block.toolUse.name || '',
}))
const nextCheckResult = checkForForcedToolUsage(
nextToolUseBlocks,
nextToolChoice,
forcedTools,
usedForcedTools
)
if (nextCheckResult) {
hasUsedForcedTool = nextCheckResult.hasUsedForcedTool
usedForcedTools = nextCheckResult.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
enrichLastModelSegmentFromBedrockResponse(timeSegments, currentResponse, {
model: request.model,
})
modelTime += thisModelTime
if (currentResponse.usage) {
tokens.input += currentResponse.usage.inputTokens || 0
tokens.output += currentResponse.usage.outputTokens || 0
tokens.total +=
(currentResponse.usage.inputTokens || 0) + (currentResponse.usage.outputTokens || 0)
const iterationCost = calculateCost(
request.model,
currentResponse.usage.inputTokens || 0,
currentResponse.usage.outputTokens || 0
)
cost.input += iterationCost.input
cost.output += iterationCost.output
cost.total += iterationCost.total
}
iterationCount++
}
if (structuredOutputTool && request.tools?.length) {
logger.info('Making final call with forced structured_output tool')
const structuredOutputStartTime = Date.now()
const structuredOutputCommand = new ConverseCommand({
modelId: bedrockModelId,
messages: currentMessages,
system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined,
inferenceConfig,
toolConfig: {
tools: [structuredOutputTool],
toolChoice: { tool: { name: structuredOutputToolName } },
},
})
const structuredResponse = await client.send(
structuredOutputCommand,
request.abortSignal ? { abortSignal: request.abortSignal } : undefined
)
const structuredOutputEndTime = Date.now()
timeSegments.push({
type: 'model',
name: 'Structured output extraction',
startTime: structuredOutputStartTime,
endTime: structuredOutputEndTime,
duration: structuredOutputEndTime - structuredOutputStartTime,
})
enrichLastModelSegmentFromBedrockResponse(timeSegments, structuredResponse, {
model: request.model,
})
modelTime += structuredOutputEndTime - structuredOutputStartTime
const structuredOutputCall = structuredResponse.output?.message?.content?.find(
(block): block is ContentBlock & { toolUse: ToolUseBlock } =>
'toolUse' in block && block.toolUse?.name === structuredOutputToolName
)
if (structuredOutputCall) {
content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2)
hasExtractedStructuredOutput = true
logger.info('Extracted structured output from forced tool call')
} else {
logger.warn('Structured output tool was forced but no tool call found in response')
}
if (structuredResponse.usage) {
tokens.input += structuredResponse.usage.inputTokens || 0
tokens.output += structuredResponse.usage.outputTokens || 0
tokens.total +=
(structuredResponse.usage.inputTokens || 0) +
(structuredResponse.usage.outputTokens || 0)
const structuredCost = calculateCost(
request.model,
structuredResponse.usage.inputTokens || 0,
structuredResponse.usage.outputTokens || 0
)
cost.input += structuredCost.input
cost.output += structuredCost.output
cost.total += structuredCost.total
}
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
if (request.stream && !shouldStreamToolCalls && !hasExtractedStructuredOutput) {
logger.info('Using streaming for final Bedrock response after tool processing')
const messagesHaveToolContent = currentMessages.some((msg) =>
msg.content?.some(
(block) =>
('toolUse' in block && block.toolUse) || ('toolResult' in block && block.toolResult)
)
)
const streamToolConfig: ToolConfiguration | undefined =
messagesHaveToolContent && request.tools?.length
? {
tools: request.tools.map((tool) => ({
toolSpec: {
name: tool.id,
description: tool.description,
inputSchema: {
json: {
type: 'object',
properties: tool.parameters.properties,
required: tool.parameters.required,
},
},
},
})),
toolChoice: { auto: {} },
}
: undefined
const streamCommand = new ConverseStreamCommand({
modelId: bedrockModelId,
messages: currentMessages,
system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined,
inferenceConfig,
toolConfig: streamToolConfig,
})
const streamResponse = await client.send(
streamCommand,
request.abortSignal ? { abortSignal: request.abortSignal } : undefined
)
if (!streamResponse.stream) {
throw new Error('No stream returned from Bedrock')
}
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,
}
const streamCost = calculateCost(request.model, usage.inputTokens, usage.outputTokens)
const tc = sumToolCosts(toolResults)
output.cost = {
input: cost.input + streamCost.input,
output: cost.output + streamCost.output,
toolCost: tc || undefined,
total: cost.total + streamCost.total + tc,
}
finalizeTiming()
}),
})
return streamingResult
}
return {
content,
model: request.model,
tokens,
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
pricing: cost.pricing,
},
toolCalls:
toolCalls.length > 0
? toolCalls.map((tc) => ({
name: tc.name,
arguments: tc.arguments as Record<string, any>,
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
result: tc.result,
}))
: undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime,
toolsTime,
firstResponseTime,
iterations: iterationCount + 1,
timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Bedrock request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+46
View File
@@ -0,0 +1,46 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { getBedrockInferenceProfileId } from '@/providers/bedrock/utils'
describe('getBedrockInferenceProfileId', () => {
it.concurrent('prefixes geo inference profile for models that require it', () => {
expect(
getBedrockInferenceProfileId('bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', 'us-east-1')
).toBe('us.anthropic.claude-sonnet-4-5-20250929-v1:0')
expect(getBedrockInferenceProfileId('bedrock/amazon.nova-pro-v1:0', 'eu-west-1')).toBe(
'eu.amazon.nova-pro-v1:0'
)
expect(
getBedrockInferenceProfileId('bedrock/meta.llama4-scout-17b-instruct-v1:0', 'us-west-2')
).toBe('us.meta.llama4-scout-17b-instruct-v1:0')
})
it.concurrent('returns already-prefixed inference profile IDs unchanged', () => {
expect(
getBedrockInferenceProfileId('us.anthropic.claude-sonnet-4-5-20250929-v1:0', 'us-east-1')
).toBe('us.anthropic.claude-sonnet-4-5-20250929-v1:0')
expect(getBedrockInferenceProfileId('global.amazon.nova-2-lite-v1:0', 'us-east-1')).toBe(
'global.amazon.nova-2-lite-v1:0'
)
})
it.concurrent('returns the bare model ID for models without geo profile support', () => {
expect(
getBedrockInferenceProfileId('bedrock/mistral.mistral-large-3-675b-instruct', 'us-east-1')
).toBe('mistral.mistral-large-3-675b-instruct')
expect(
getBedrockInferenceProfileId('bedrock/mistral.ministral-3-8b-instruct', 'eu-west-1')
).toBe('mistral.ministral-3-8b-instruct')
expect(getBedrockInferenceProfileId('bedrock/cohere.command-r-plus-v1:0', 'us-east-1')).toBe(
'cohere.command-r-plus-v1:0'
)
expect(
getBedrockInferenceProfileId('bedrock/mistral.mixtral-8x7b-instruct-v0:1', 'ap-southeast-1')
).toBe('mistral.mixtral-8x7b-instruct-v0:1')
expect(
getBedrockInferenceProfileId('bedrock/amazon.titan-text-premier-v1:0', 'us-east-1')
).toBe('amazon.titan-text-premier-v1:0')
})
})
+143
View File
@@ -0,0 +1,143 @@
import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime'
import { createLogger } from '@sim/logger'
import { randomFloat } from '@sim/utils/random'
import { trackForcedToolUsage } from '@/providers/utils'
const logger = createLogger('BedrockUtils')
export interface BedrockStreamUsage {
inputTokens: number
outputTokens: number
}
export function createReadableStreamFromBedrockStream(
bedrockStream: AsyncIterable<ConverseStreamOutput>,
onComplete?: (content: string, usage: BedrockStreamUsage) => void
): ReadableStream<Uint8Array> {
let fullContent = ''
let inputTokens = 0
let outputTokens = 0
return new ReadableStream({
async start(controller) {
try {
for await (const event of bedrockStream) {
if (event.contentBlockDelta?.delta?.text) {
const text = event.contentBlockDelta.delta.text
fullContent += text
controller.enqueue(new TextEncoder().encode(text))
} else if (event.metadata?.usage) {
inputTokens = event.metadata.usage.inputTokens ?? 0
outputTokens = event.metadata.usage.outputTokens ?? 0
}
}
if (onComplete) {
onComplete(fullContent, { inputTokens, outputTokens })
}
controller.close()
} catch (err) {
controller.error(err)
}
},
})
}
export function checkForForcedToolUsage(
toolUseBlocks: Array<{ name: string }>,
toolChoice: any,
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } | null {
if (typeof toolChoice === 'object' && toolChoice !== null && toolUseBlocks.length > 0) {
const adaptedToolCalls = toolUseBlocks.map((tool) => ({ name: tool.name }))
const adaptedToolChoice = toolChoice.tool
? { function: { name: toolChoice.tool.name } }
: toolChoice
return trackForcedToolUsage(
adaptedToolCalls,
adaptedToolChoice,
logger,
'bedrock',
forcedTools,
usedForcedTools
)
}
return null
}
/**
* Generates a unique tool use ID for Bedrock.
* AWS Bedrock requires toolUseId to be 1-64 characters, pattern [a-zA-Z0-9_-]+
*/
export function generateToolUseId(toolName: string): string {
const timestamp = Date.now().toString(36) // Base36 timestamp (9 chars)
const random = randomFloat().toString(36).substring(2, 7) // 5 random chars
const suffix = `-${timestamp}-${random}` // ~15 chars
const maxNameLength = 64 - suffix.length
const truncatedName = toolName.substring(0, maxNameLength).replace(/[^a-zA-Z0-9_-]/g, '_')
return `${truncatedName}${suffix}`
}
/**
* Models whose AWS model cards state geo/cross-region inference profiles are
* not supported ("Geo inference ID: Not supported"). These must be invoked
* with the bare in-region model ID — prefixing them with a geo profile
* (e.g. us.mistral...) produces an invalid model identifier.
*/
const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([
'mistral.mistral-large-3-675b-instruct',
'mistral.mistral-large-2411-v1:0',
'mistral.mistral-large-2407-v1:0',
'mistral.magistral-small-2509',
'mistral.ministral-3-14b-instruct',
'mistral.ministral-3-8b-instruct',
'mistral.ministral-3-3b-instruct',
'mistral.mixtral-8x7b-instruct-v0:1',
'amazon.titan-text-premier-v1:0',
'cohere.command-r-v1:0',
'cohere.command-r-plus-v1:0',
])
/**
* Converts a model ID to the Bedrock inference profile format.
* AWS Bedrock requires inference profile IDs (e.g., us.anthropic.claude-...)
* for on-demand invocation of newer models, while some models only accept
* the bare in-region model ID.
*
* @param modelId - The model ID (e.g., "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0")
* @param region - The AWS region (e.g., "us-east-1")
* @returns The inference profile ID (e.g., "us.anthropic.claude-sonnet-4-5-20250929-v1:0")
*/
export function getBedrockInferenceProfileId(modelId: string, region: string): string {
const baseModelId = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId
if (/^(us-gov|us|eu|apac|au|ca|jp|global)\./.test(baseModelId)) {
return baseModelId
}
if (GEO_PROFILE_UNSUPPORTED_MODEL_IDS.has(baseModelId)) {
return baseModelId
}
let inferencePrefix: string
if (region.startsWith('us-gov-')) {
inferencePrefix = 'us-gov'
} else if (region.startsWith('us-') || region.startsWith('ca-')) {
inferencePrefix = 'us'
} else if (region.startsWith('eu-') || region === 'il-central-1') {
inferencePrefix = 'eu'
} else if (region.startsWith('ap-') || region.startsWith('me-')) {
inferencePrefix = 'apac'
} else if (region.startsWith('sa-')) {
inferencePrefix = 'us'
} else if (region.startsWith('af-')) {
inferencePrefix = 'eu'
} else {
inferencePrefix = 'us'
}
return `${inferencePrefix}.${baseModelId}`
}
+561
View File
@@ -0,0 +1,561 @@
import { Cerebras } from '@cerebras/cerebras_cloud_sdk'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
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,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('CerebrasProvider')
export const cerebrasProvider: ProviderConfig = {
id: 'cerebras',
name: 'Cerebras',
description: 'Cerebras Cloud LLMs',
version: '1.0.0',
models: getProviderModels('cerebras'),
defaultModel: getProviderDefaultModel('cerebras'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Cerebras')
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const client = new Cerebras({
apiKey: request.apiKey,
})
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'cerebras')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model.replace('cerebras/', ''),
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.responseFormat) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
}
let originalToolChoice: any
let forcedTools: string[] = []
let hasFilteredTools = false
if (tools?.length) {
const preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai')
if (preparedTools.tools?.length) {
payload.tools = preparedTools.tools
payload.tool_choice = preparedTools.toolChoice || 'auto'
originalToolChoice = preparedTools.toolChoice
forcedTools = preparedTools.forcedTools || []
hasFilteredTools = preparedTools.hasFilteredTools
logger.info('Cerebras request configuration:', {
toolCount: preparedTools.tools.length,
toolChoice: payload.tool_choice,
forcedToolsCount: forcedTools.length,
hasFilteredTools,
model: request.model,
})
}
}
if (request.stream && (!tools || tools.length === 0)) {
logger.info('Using streaming response for Cerebras request (no tools)')
const streamResponse: any = await client.chat.completions.create(
{
...payload,
stream: true,
},
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const initialCallTime = Date.now()
let currentResponse = (await client.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)) as CerebrasResponse
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
const processedToolCallIds = new Set()
const toolCallSignatures = new Set()
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'cerebras' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
break
}
const toolsStartTime = Date.now()
let hasRepeatedToolCalls = false
const filteredToolCalls = toolCallsInResponse.filter((toolCall) => {
if (processedToolCallIds.has(toolCall.id)) {
return false
}
const toolCallSignature = `${toolCall.function.name}-${toolCall.function.arguments}`
if (toolCallSignatures.has(toolCallSignature)) {
hasRepeatedToolCalls = true
return false
}
processedToolCallIds.add(toolCall.id)
toolCallSignatures.add(toolCallSignature)
return true
})
const processedAnyToolCall = filteredToolCalls.length > 0
const toolExecutionPromises = filteredToolCalls.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call (Cerebras):', {
error: toError(error).message,
toolName,
})
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: filteredToolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
let usedForcedTools: string[] = []
if (typeof originalToolChoice === 'object' && forcedTools.length > 0) {
const toolTracking = trackForcedToolUsage(
currentResponse.choices[0]?.message?.tool_calls,
originalToolChoice,
logger,
'openai',
forcedTools,
usedForcedTools
)
usedForcedTools = toolTracking.usedForcedTools
const nextToolChoice = toolTracking.nextToolChoice
if (nextToolChoice && typeof nextToolChoice === 'object') {
payload.tool_choice = nextToolChoice
} else if (nextToolChoice === 'auto' || !nextToolChoice) {
payload.tool_choice = 'auto'
}
}
if (processedAnyToolCall || hasRepeatedToolCalls) {
const nextModelStartTime = Date.now()
const finalPayload = {
...payload,
messages: currentMessages,
}
finalPayload.tool_choice = 'none'
const finalResponse = (await client.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)) as CerebrasResponse
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (finalResponse.choices[0]?.message?.content) {
content = finalResponse.choices[0].message.content
}
if (finalResponse.usage) {
tokens.input += finalResponse.usage.prompt_tokens || 0
tokens.output += finalResponse.usage.completion_tokens || 0
tokens.total += finalResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
finalResponse,
finalResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'cerebras' }
)
break
}
if (!processedAnyToolCall && !hasRepeatedToolCalls) {
const nextPayload = {
...payload,
messages: currentMessages,
}
const nextModelStartTime = Date.now()
currentResponse = (await client.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)) as CerebrasResponse
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'cerebras' }
)
}
} catch (error) {
logger.error('Error in Cerebras tool processing:', { error })
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
if (request.stream) {
logger.info('Using streaming for final Cerebras response after tool processing')
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
stream: true,
}
const streamResponse: any = await client.chat.completions.create(
streamingPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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
}
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Cerebras request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
/**
* Enriches the last model segment with per-iteration content from a Chat
* Completions response: assistant text, tool calls, finish reason, token usage.
*/
+34
View File
@@ -0,0 +1,34 @@
interface CerebrasMessage {
role: string
content: string | null
tool_calls?: Array<{
id: string
type: 'function'
function: {
name: string
arguments: string
}
}>
tool_call_id?: string
}
interface CerebrasChoice {
message: CerebrasMessage
index: number
finish_reason: string
}
interface CerebrasUsage {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
export interface CerebrasResponse {
id: string
object: string
created: number
model: string
choices: CerebrasChoice[]
usage: CerebrasUsage
}
+26
View File
@@ -0,0 +1,26 @@
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
interface CerebrasChunk {
choices?: Array<{
delta?: {
content?: string
}
}>
usage?: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
}
}
/**
* Creates a ReadableStream from a Cerebras streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromCerebrasStream(
cerebrasStream: AsyncIterable<CerebrasChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(cerebrasStream as any, 'Cerebras', onComplete)
}
+107
View File
@@ -0,0 +1,107 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { getCachedProviderClient } from '@/providers/client-cache'
/**
* Builds a fresh fake "client" object on every call so identity comparisons
* (`toBe`) tell us whether the cache returned the memoized instance or a new one
* from the factory. We never construct a real SDK client — these tests exercise
* the cache, not any provider SDK.
*/
function makeFactory() {
return vi.fn(() => ({}) as object)
}
/**
* Generates a unique suffix per test so distinct tests never collide on cache
* keys. The cache util exposes no reset hook, so isolation is achieved by
* namespacing keys rather than clearing shared state.
*/
let keyCounter = 0
function uniqueNs(): string {
keyCounter += 1
return `ns-${keyCounter}-${Date.now()}`
}
describe('getCachedProviderClient', () => {
it('returns the SAME instance for an identical key and runs the factory once (memoized)', () => {
const key = `anthropic::${uniqueNs()}::default`
const factory = makeFactory()
const first = getCachedProviderClient(key, factory)
const second = getCachedProviderClient(key, factory)
expect(second).toBe(first)
expect(factory).toHaveBeenCalledTimes(1)
})
it('returns a DIFFERENT instance for a different apiKey (tenant isolation)', () => {
const ns = uniqueNs()
const factoryA = makeFactory()
const factoryB = makeFactory()
const tenantA = getCachedProviderClient(`anthropic::${ns}-tenant-a::default`, factoryA)
const tenantB = getCachedProviderClient(`anthropic::${ns}-tenant-b::default`, factoryB)
expect(tenantB).not.toBe(tenantA)
expect(factoryA).toHaveBeenCalledTimes(1)
expect(factoryB).toHaveBeenCalledTimes(1)
})
it('namespaces by provider: the same apiKey under different provider prefixes does not collide', () => {
const ns = uniqueNs()
const apiKey = `${ns}-shared-key`
const anthropicFactory = makeFactory()
const bedrockFactory = makeFactory()
const anthropicClient = getCachedProviderClient(`anthropic::${apiKey}`, anthropicFactory)
const bedrockClient = getCachedProviderClient(`bedrock::${apiKey}`, bedrockFactory)
expect(bedrockClient).not.toBe(anthropicClient)
})
it('treats every distinct key dimension as a distinct client', () => {
const ns = uniqueNs()
const base = `azure-anthropic::${ns}-key::https://a.example.com::2023-06-01::10.0.0.1::default`
const baseFactory = makeFactory()
const baseClient = getCachedProviderClient(base, baseFactory)
const variants = [
`azure-anthropic::${ns}-key::https://b.example.com::2023-06-01::10.0.0.1::default`,
`azure-anthropic::${ns}-key::https://a.example.com::2024-10-22::10.0.0.1::default`,
`azure-anthropic::${ns}-key::https://a.example.com::2023-06-01::10.0.0.2::default`,
`azure-anthropic::${ns}-key::https://a.example.com::2023-06-01::no-pin::default`,
`azure-anthropic::${ns}-key::https://a.example.com::2023-06-01::10.0.0.1::beta`,
]
for (const key of variants) {
const factory = makeFactory()
const client = getCachedProviderClient(key, factory)
expect(client).not.toBe(baseClient)
expect(factory).toHaveBeenCalledTimes(1)
}
})
it('evicts the least-recently-used entry once the cache cap is exceeded', () => {
const ns = uniqueNs()
const CAP = 1_000
const oldestKey = `evict::${ns}::0`
const oldestFactory = makeFactory()
getCachedProviderClient(oldestKey, oldestFactory)
expect(oldestFactory).toHaveBeenCalledTimes(1)
// Fill the remaining capacity, then push one past the cap. The oldest key has
// not been touched since insertion, so it is the LRU eviction victim.
for (let i = 1; i <= CAP; i += 1) {
getCachedProviderClient(`evict::${ns}::${i}`, makeFactory())
}
const reFactory = makeFactory()
getCachedProviderClient(oldestKey, reFactory)
expect(reFactory).toHaveBeenCalledTimes(1)
expect(oldestFactory).toHaveBeenCalledTimes(1)
})
})
+36
View File
@@ -0,0 +1,36 @@
import { LRUCache } from 'lru-cache'
const CLIENT_CACHE_MAX_ENTRIES = 1_000
const CLIENT_CACHE_TTL_MS = 30 * 60 * 1_000
/**
* `updateAgeOnGet` makes the TTL idle-based: a continuously-used client keeps its
* warm keep-alive connections, while idle keys age out.
*/
const clientCache = new LRUCache<string, object>({
max: CLIENT_CACHE_MAX_ENTRIES,
ttl: CLIENT_CACHE_TTL_MS,
updateAgeOnGet: true,
})
/**
* Memoizes provider SDK clients so connections stay warm across requests rather
* than re-handshaking per call. The key must be namespaced per provider and
* encode every input that varies the client; the API key is always part of it,
* making it the tenant boundary (clients are never shared across keys).
*/
export function getCachedProviderClient<T extends object>(key: string, factory: () => T): T {
const existing = clientCache.get(key)
if (existing) {
return existing as T
}
const client = factory()
clientCache.set(key, client)
return client
}
/** Clears the cache so tests asserting client construction start from a miss. */
export function clearProviderClientCacheForTests(): void {
clientCache.clear()
}
+544
View File
@@ -0,0 +1,544 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types'
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,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('DeepseekProvider')
export const deepseekProvider: ProviderConfig = {
id: 'deepseek',
name: 'Deepseek',
description: "Deepseek's chat models",
version: '1.0.0',
models: getProviderModels('deepseek'),
defaultModel: getProviderDefaultModel('deepseek'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Deepseek')
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const deepseek = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://api.deepseek.com/v1',
})
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'deepseek')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'deepseek')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
logger.info('Deepseek request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: toolChoice.type === 'tool'
? `force:${toolChoice.name}`
: toolChoice.type === 'any'
? `force:${toolChoice.any?.name || 'unknown'}`
: 'unknown',
model: request.model,
})
}
}
if (request.stream && (!tools || tools.length === 0)) {
logger.info('Using streaming response for DeepSeek request (no tools)')
const streamResponse = await deepseek.chat.completions.create(
{
...payload,
stream: true,
},
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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,
}
const costResult = calculateCost(
request.model,
usage.prompt_tokens,
usage.completion_tokens
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await deepseek.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
if (content) {
content = content.replace(/```json\n?|\n?```/g, '')
content = content.trim()
}
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let hasUsedForcedTool = false
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
if (
typeof originalToolChoice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
originalToolChoice,
logger,
'deepseek',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'deepseek' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await deepseek.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
if (
typeof nextPayload.tool_choice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
nextPayload.tool_choice,
logger,
'deepseek',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
content = content.replace(/```json\n?|\n?```/g, '')
content = content.trim()
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'deepseek' }
)
}
} catch (error) {
logger.error('Error in Deepseek request:', { error })
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
if (request.stream) {
logger.info('Using streaming for final DeepSeek response after tool processing')
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
stream: true,
}
const streamResponse = await deepseek.chat.completions.create(
streamingPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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,
}
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
}
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Deepseek request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a DeepSeek streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromDeepseekStream(
deepseekStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(deepseekStream, 'Deepseek', onComplete)
}
@@ -0,0 +1,261 @@
import { FileState, GoogleGenAI } from '@google/genai'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { StorageService } from '@/lib/uploads'
import { resolveTrustedFileContext } from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { verifyFileAccess } from '@/app/api/files/authorization'
import type { UserFile } from '@/executor/types'
import {
getProviderAttachmentMaxBytes,
getProviderFileStrategy,
inferAttachmentMimeType,
shouldUseLargeFilePath,
} from '@/providers/attachments'
import type { Message, ProviderId, ProviderRequest } from '@/providers/types'
const logger = createLogger('ProviderFileAttachments')
const OPENAI_FILES_ENDPOINT = 'https://api.openai.com/v1/files'
const PRESIGNED_URL_EXPIRY_SECONDS = 60 * 60
/** OpenAI auto-deletes uploaded files after this window — see the "rely on provider expiry" lifecycle. */
const OPENAI_FILE_EXPIRY_SECONDS = 60 * 60
const GEMINI_POLL_INTERVAL_MS = 1000
const GEMINI_PROCESSING_TIMEOUT_MS = 5 * 60_000
function* iterateRequestFiles(messages: Message[] | undefined): Generator<UserFile> {
for (const message of messages ?? []) {
for (const file of message.files ?? []) {
yield file
}
}
}
/**
* Resolves every attachment that exceeds the inline threshold on a large-file-capable
* provider to a short-lived signed URL on `file.remoteUrl`. `remote-url` providers send it
* to the model directly; for `files-api` providers it marks the file for upload (the bytes
* are read from storage at upload time). Requires cloud storage — a large file (already past
* the inline base64 cap) cannot be sent without it, so the request fails with a clear error.
*
* Runs for every request in {@link executeProviderRequest} (after the API key resolves), so
* the server-only handle fields are first cleared on every file for every provider — a forged
* handle on an untrusted request body can never survive to a builder or trigger a fetch.
*/
export async function attachLargeFileRemoteUrls(
request: ProviderRequest,
providerId: ProviderId | string
): Promise<void> {
for (const file of iterateRequestFiles(request.messages)) {
file.providerFileId = undefined
file.providerFileUri = undefined
file.remoteUrl = undefined
}
if (getProviderFileStrategy(providerId) === 'inline') return
const requestId = request.workflowId ?? 'provider-request'
const maxBytes = getProviderAttachmentMaxBytes(providerId)
for (const file of iterateRequestFiles(request.messages)) {
if (!file.key || !shouldUseLargeFilePath(file, providerId)) continue
if (Number.isFinite(file.size) && file.size > maxBytes) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2)
const maxMB = (maxBytes / (1024 * 1024)).toFixed(0)
throw new Error(
`File "${file.name}" (${sizeMB}MB) exceeds the ${maxMB}MB agent attachment limit for provider "${providerId}"`
)
}
if (!StorageService.hasCloudStorage()) {
logger.warn(
`[${requestId}] "${file.name}" exceeds the inline limit for "${providerId}" but cloud storage is unavailable`
)
throw new Error(
`File "${file.name}" exceeds the inline attachment limit and requires cloud file storage, which is not configured`
)
}
if (!request.userId) {
throw new Error(
`File "${file.name}" requires an authenticated user for provider "${providerId}"`
)
}
const context = resolveTrustedFileContext(file.key, file.context)
const hasAccess = await verifyFileAccess(file.key, request.userId, undefined, context, false)
if (!hasAccess) {
throw new Error(`File "${file.name}" is not accessible for provider "${providerId}"`)
}
file.remoteUrl = await StorageService.generatePresignedDownloadUrl(
file.key,
context,
PRESIGNED_URL_EXPIRY_SECONDS
)
}
}
/**
* For `files-api` providers, uploads each large attachment (already carrying a signed
* `remoteUrl` from {@link attachLargeFileRemoteUrls}) to the provider Files API and records
* the returned handle on the file. Runs after the request's API key is resolved so hosted
* and BYOK keys both work.
*/
export async function uploadLargeFilesToProvider(
request: ProviderRequest,
providerId: ProviderId | string
): Promise<void> {
if (getProviderFileStrategy(providerId) !== 'files-api') return
const groups = groupUploadableFiles(request.messages)
if (groups.length === 0) return
const maxBytes = getProviderAttachmentMaxBytes(providerId)
const ai = providerId === 'google' ? new GoogleGenAI({ apiKey: request.apiKey }) : null
for (const group of groups) {
const [representative] = group
await assertFileAccessForUpload(representative, request.userId)
if (providerId === 'openai') {
await uploadOpenAIFile(representative, request.apiKey, maxBytes, request.abortSignal)
} else if (ai) {
await uploadGeminiFile(representative, ai, maxBytes, request.abortSignal)
}
for (const file of group) {
file.providerFileId = representative.providerFileId
file.providerFileUri = representative.providerFileUri
}
}
}
/**
* Verifies the caller may read this file before its bytes are uploaded to a provider. Enforced
* for every caller of {@link uploadLargeFilesToProvider} (not just the agent path), so a forged
* storage key in a passthrough request cannot exfiltrate another user's file.
*/
async function assertFileAccessForUpload(
file: UserFile,
userId: string | undefined
): Promise<void> {
if (!file.key) {
throw new Error(`File "${file.name}" has no storage key`)
}
if (!userId) {
throw new Error(`File "${file.name}" requires an authenticated user to upload`)
}
const context = resolveTrustedFileContext(file.key, file.context)
const hasAccess = await verifyFileAccess(file.key, userId, undefined, context, false)
if (!hasAccess) {
throw new Error(`File "${file.name}" is not accessible`)
}
}
/**
* Groups large files needing a Files API upload by storage key so a file referenced across
* multiple messages uploads once; the resulting handle is then applied to every occurrence.
*/
function groupUploadableFiles(messages: Message[] | undefined): UserFile[][] {
const groups = new Map<string, UserFile[]>()
for (const message of messages ?? []) {
for (const file of message.files ?? []) {
if (!file.remoteUrl || file.providerFileId || file.providerFileUri) continue
const dedupeKey = file.key || file.remoteUrl
const group = groups.get(dedupeKey)
if (group) group.push(file)
else groups.set(dedupeKey, [file])
}
}
return [...groups.values()]
}
/**
* Reads the file bytes straight from storage via the storage SDK (not by HTTP-fetching the
* signed URL), so there is no server-side URL fetch to be an SSRF vector and internal
* object storage works. Bounded by the provider's attachment ceiling.
*/
async function downloadFileForUpload(file: UserFile, maxBytes: number): Promise<Blob> {
const { buffer, contentType } = await downloadServableFileFromStorage(
file,
'provider-file-upload',
logger,
{ maxBytes }
)
return new Blob([new Uint8Array(buffer)], {
type: contentType || file.type || inferAttachmentMimeType(file),
})
}
/**
* Uploads to `POST /v1/files` via multipart directly (not the SDK), because the installed
* `openai` SDK does not type `expires_after`; the bracketed form fields are the documented
* multipart encoding for the nested object and give the file an auto-expiry.
*/
async function uploadOpenAIFile(
file: UserFile,
apiKey: string | undefined,
maxBytes: number,
signal?: AbortSignal
): Promise<void> {
const mimeType = inferAttachmentMimeType(file)
const blob = await downloadFileForUpload(file, maxBytes)
const form = new FormData()
form.append('purpose', mimeType.startsWith('image/') ? 'vision' : 'user_data')
form.append('expires_after[anchor]', 'created_at')
form.append('expires_after[seconds]', String(OPENAI_FILE_EXPIRY_SECONDS))
form.append('file', blob, file.name)
const response = await fetch(OPENAI_FILES_ENDPOINT, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
signal,
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`OpenAI file upload failed for "${file.name}" (${response.status}): ${detail}`)
}
const uploaded = (await response.json()) as { id?: string }
if (!uploaded.id) {
throw new Error(`OpenAI file upload for "${file.name}" returned no id`)
}
file.providerFileId = uploaded.id
logger.info(`Uploaded "${file.name}" to OpenAI Files API`, { fileId: uploaded.id })
}
async function uploadGeminiFile(
file: UserFile,
ai: GoogleGenAI,
maxBytes: number,
signal?: AbortSignal
): Promise<void> {
const mimeType = inferAttachmentMimeType(file)
const blob = await downloadFileForUpload(file, maxBytes)
let uploaded = await ai.files.upload({ file: blob, config: { mimeType, abortSignal: signal } })
if (!uploaded.name) {
throw new Error(`Gemini upload for "${file.name}" returned no file name`)
}
const uploadedName = uploaded.name
const deadline = Date.now() + GEMINI_PROCESSING_TIMEOUT_MS
while (uploaded.state === FileState.PROCESSING) {
if (Date.now() > deadline) {
throw new Error(`Gemini file processing timed out for "${file.name}"`)
}
await sleep(GEMINI_POLL_INTERVAL_MS)
uploaded = await ai.files.get({ name: uploadedName })
}
if (uploaded.state === FileState.FAILED || !uploaded.uri) {
throw new Error(
`Gemini file processing failed for "${file.name}": ${getErrorMessage(uploaded.error, 'unknown error')}`
)
}
file.providerFileUri = uploaded.uri
logger.info(`Uploaded "${file.name}" to Gemini File API`, { fileUri: uploaded.uri })
}
+232
View File
@@ -0,0 +1,232 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCreate,
mockSupportsNativeStructuredOutputs,
mockPrepareToolsWithUsageControl,
mockExecuteTool,
} = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockSupportsNativeStructuredOutputs: vi.fn(),
mockPrepareToolsWithUsageControl: vi.fn(),
mockExecuteTool: vi.fn(),
}))
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(
class {
chat = { completions: { create: mockCreate } }
}
),
}))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn().mockReturnValue([]),
getProviderDefaultModel: vi.fn().mockReturnValue('llama-v3p1-70b-instruct'),
}))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: vi.fn((messages) => messages),
}))
vi.mock('@/providers/fireworks/utils', () => ({
supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs,
createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream),
checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })),
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }),
generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'),
prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })),
prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl,
sumToolCosts: vi.fn().mockReturnValue(0),
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
import { fireworksProvider } from '@/providers/fireworks/index'
import { ProviderError } from '@/providers/types'
const textResponse = (content: string) => ({
choices: [{ message: { content, tool_calls: [] } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
})
const toolCallResponse = () => ({
choices: [
{
message: {
content: null,
tool_calls: [
{ id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } },
],
},
},
],
usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 },
})
const toolDef = {
id: 'my_tool',
name: 'my_tool',
description: '',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
}
const callBody = (index: number) => mockCreate.mock.calls[index][0]
const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0]
describe('fireworksProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSupportsNativeStructuredOutputs.mockResolvedValue(true)
mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({
tools,
toolChoice: 'auto',
forcedTools: [],
}))
mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } })
})
const baseRequest = {
model: 'fireworks/llama-v3p1-70b-instruct',
systemPrompt: 'You are helpful.',
messages: [{ role: 'user' as const, content: 'Hello' }],
apiKey: 'fw-test-key',
}
it('throws when the API key is missing', async () => {
await expect(
fireworksProvider.executeRequest({ ...baseRequest, apiKey: undefined })
).rejects.toThrow('API key is required for Fireworks')
})
it('returns content and token usage for a simple request', async () => {
mockCreate.mockResolvedValueOnce(textResponse('hi there'))
const result = await fireworksProvider.executeRequest(baseRequest)
expect(result).toMatchObject({
content: 'hi there',
model: 'llama-v3p1-70b-instruct',
tokens: { input: 10, output: 5, total: 15 },
})
})
it('wraps API errors in a ProviderError', async () => {
mockCreate.mockRejectedValueOnce(new Error('boom'))
await expect(fireworksProvider.executeRequest(baseRequest)).rejects.toBeInstanceOf(
ProviderError
)
})
it('streams directly when there are no tools', async () => {
mockCreate.mockResolvedValueOnce({})
const result = await fireworksProvider.executeRequest({ ...baseRequest, stream: true })
expect(lastCallBody()).toMatchObject({ stream: true, stream_options: { include_usage: true } })
expect(result).toHaveProperty('stream')
expect(result).toHaveProperty('execution')
})
it('sends a json_schema response_format with no strict field', async () => {
mockCreate.mockResolvedValueOnce(textResponse('{}'))
await fireworksProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' }, strict: true },
})
expect(lastCallBody().response_format).toEqual({
type: 'json_schema',
json_schema: { name: 'my_schema', schema: { type: 'object' } },
})
expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict')
})
it('falls back to json_object with prompt instructions when native is unsupported', async () => {
mockSupportsNativeStructuredOutputs.mockResolvedValue(false)
mockCreate.mockResolvedValueOnce(textResponse('{}'))
await fireworksProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' } },
})
expect(lastCallBody().response_format).toEqual({ type: 'json_object' })
expect(lastCallBody().messages.at(-1)).toEqual({
role: 'user',
content: 'SCHEMA_INSTRUCTIONS',
})
})
it('defers response_format to a final call when tools are active', async () => {
mockCreate
.mockResolvedValueOnce(textResponse('intermediate'))
.mockResolvedValueOnce(textResponse('{"done":true}'))
await fireworksProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' } },
tools: [toolDef],
})
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(callBody(0).response_format).toBeUndefined()
expect(callBody(0).tools).toBeDefined()
expect(callBody(1).response_format).toEqual({
type: 'json_schema',
json_schema: { name: 'my_schema', schema: { type: 'object' } },
})
expect(callBody(1).tools).toBeUndefined()
})
it('runs the tool loop and threads tool results back into the conversation', async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse())
.mockResolvedValueOnce(textResponse('final answer'))
const result = await fireworksProvider.executeRequest({ ...baseRequest, tools: [toolDef] })
expect(mockExecuteTool).toHaveBeenCalledWith('my_tool', { x: 1 }, expect.anything())
expect(result).toMatchObject({ content: 'final answer' })
expect((result as { toolCalls?: unknown[] }).toolCalls).toHaveLength(1)
const followUpMessages = callBody(1).messages
expect(followUpMessages).toContainEqual(
expect.objectContaining({ role: 'assistant', tool_calls: expect.any(Array) })
)
expect(followUpMessages).toContainEqual(
expect.objectContaining({ role: 'tool', tool_call_id: 'call_1' })
)
})
it("forces tool_choice 'none' on the final streaming call after tools run", async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse())
.mockResolvedValueOnce(textResponse('done'))
.mockResolvedValueOnce({})
await fireworksProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] })
expect(mockCreate).toHaveBeenCalledTimes(3)
expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true })
})
})
+599
View File
@@ -0,0 +1,599 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import {
checkForForcedToolUsage,
createReadableStreamFromOpenAIStream,
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,
Message,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
generateSchemaInstructions,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('FireworksProvider')
/**
* Applies structured output configuration to a payload based on model capabilities.
* Uses native json_schema for supported models, falls back to json_object with prompt instructions.
*/
async function applyResponseFormat(
targetPayload: any,
messages: any[],
responseFormat: any,
model: string
): Promise<any[]> {
const useNative = await supportsNativeStructuredOutputs(model)
if (useNative) {
logger.info('Using native structured outputs for Fireworks model', { model })
targetPayload.response_format = {
type: 'json_schema',
json_schema: {
name: responseFormat.name || 'response_schema',
schema: responseFormat.schema || responseFormat,
},
}
return messages
}
logger.info('Using json_object mode with prompt instructions for Fireworks model', { model })
const schema = responseFormat.schema || responseFormat
const schemaInstructions = generateSchemaInstructions(schema, responseFormat.name)
targetPayload.response_format = { type: 'json_object' }
return [...messages, { role: 'user', content: schemaInstructions }]
}
export const fireworksProvider: ProviderConfig = {
id: 'fireworks',
name: 'Fireworks',
description: 'Fast inference for open-source models via Fireworks AI',
version: '1.0.0',
models: getProviderModels('fireworks'),
defaultModel: getProviderDefaultModel('fireworks'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Fireworks')
}
const client = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://api.fireworks.ai/inference/v1',
})
const requestedModel = request.model.replace(/^fireworks\//, '')
logger.info('Preparing Fireworks request', {
model: requestedModel,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({ role: 'system', content: request.systemPrompt })
}
if (request.context) {
allMessages.push({ role: 'user', content: request.context })
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'fireworks') as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: requestedModel,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'fireworks')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.responseFormat && !hasActiveTools) {
payload.messages = await applyResponseFormat(
payload,
payload.messages,
request.responseFormat,
requestedModel
)
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 costResult = calculateCost(
requestedModel,
usage.prompt_tokens,
usage.completion_tokens
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await client.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls: FunctionCallResponse[] = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
const forcedToolResult = checkForForcedToolUsage(
currentResponse,
originalToolChoice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = forcedToolResult.hasUsedForcedTool
usedForcedTools = forcedToolResult.usedForcedTools
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'fireworks' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call (Fireworks):', {
error: toError(error).message,
toolName,
})
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success) {
toolResults.push(result.output!)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } }
} else {
nextPayload.tool_choice = 'auto'
}
}
const nextModelStartTime = Date.now()
currentResponse = await client.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const nextForcedToolResult = checkForForcedToolUsage(
currentResponse,
nextPayload.tool_choice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool
usedForcedTools = nextForcedToolResult.usedForcedTools
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'fireworks' }
)
}
if (request.stream) {
const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: [...currentMessages],
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
if (request.responseFormat) {
;(streamingParams as any).messages = await applyResponseFormat(
streamingParams as any,
streamingParams.messages,
request.responseFormat,
requestedModel
)
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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,
},
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
}
if (request.responseFormat && hasActiveTools) {
const finalPayload: any = {
model: payload.model,
messages: [...currentMessages],
}
if (payload.temperature !== undefined) {
finalPayload.temperature = payload.temperature
}
if (payload.max_tokens !== undefined) {
finalPayload.max_tokens = payload.max_tokens
}
finalPayload.messages = await applyResponseFormat(
finalPayload,
finalPayload.messages,
request.responseFormat,
requestedModel
)
const finalStartTime = Date.now()
const finalResponse = await client.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalEndTime = Date.now()
const finalDuration = finalEndTime - finalStartTime
timeSegments.push({
type: 'model',
name: 'Final structured response',
startTime: finalStartTime,
endTime: finalEndTime,
duration: finalDuration,
})
modelTime += finalDuration
if (finalResponse.choices[0]?.message?.content) {
content = finalResponse.choices[0].message.content
}
if (finalResponse.usage) {
tokens.input += finalResponse.usage.prompt_tokens || 0
tokens.output += finalResponse.usage.completion_tokens || 0
tokens.total += finalResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
finalResponse,
finalResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'fireworks' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: requestedModel,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
const errorDetails: Record<string, any> = {
error: toError(error).message,
duration: totalDuration,
}
if (error && typeof error === 'object') {
const err = error as any
if (err.status) errorDetails.status = err.status
if (err.code) errorDetails.code = err.code
if (err.type) errorDetails.type = err.type
if (err.error?.message) errorDetails.providerMessage = err.error.message
if (err.error?.metadata) errorDetails.metadata = err.error.metadata
}
logger.error('Error in Fireworks request:', errorDetails)
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+41
View File
@@ -0,0 +1,41 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils'
/**
* Checks if a model supports native structured outputs (json_schema).
* Fireworks AI supports structured outputs across their inference API.
*/
export async function supportsNativeStructuredOutputs(_modelId: string): Promise<boolean> {
return true
}
/**
* Creates a ReadableStream from a Fireworks streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromOpenAIStream(
openaiStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(openaiStream, 'Fireworks', onComplete)
}
/**
* Checks if a forced tool was used in a Fireworks response.
* Uses the shared OpenAI-compatible forced tool usage helper.
*/
export function checkForForcedToolUsage(
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } {
return checkForForcedToolUsageOpenAI(
response,
toolChoice,
'Fireworks',
forcedTools,
usedForcedTools
)
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
import type { Content, ToolConfig } from '@google/genai'
import type { FunctionCallResponse, ModelPricing, TimeSegment } from '@/providers/types'
/**
* Usage metadata from Gemini responses
*/
export interface GeminiUsage {
promptTokenCount: number
candidatesTokenCount: number
totalTokenCount: number
}
/**
* Parsed function call from Gemini response
*/
interface ParsedFunctionCall {
name: string
args: Record<string, unknown>
}
/**
* Accumulated state during tool execution loop
*/
export interface ExecutionState {
contents: Content[]
tokens: { input: number; output: number; total: number }
cost: { input: number; output: number; total: number; pricing: ModelPricing }
toolCalls: FunctionCallResponse[]
toolResults: Record<string, unknown>[]
iterationCount: number
modelTime: number
toolsTime: number
timeSegments: TimeSegment[]
usedForcedTools: string[]
currentToolConfig: ToolConfig | undefined
}
/**
* Result from forced tool usage check
*/
interface ForcedToolResult {
hasUsedForcedTool: boolean
usedForcedTools: string[]
nextToolConfig: ToolConfig | undefined
}
/**
* Configuration for creating a Gemini client
*/
interface GeminiClientConfig {
/** For Google Gemini API */
apiKey?: string
/** For Vertex AI */
vertexai?: boolean
project?: string
location?: string
/** OAuth access token for Vertex AI */
accessToken?: string
}
/**
* Provider type for logging and model lookup
*/
export type GeminiProviderType = 'google' | 'vertex'
+42
View File
@@ -0,0 +1,42 @@
import { GoogleGenAI } from '@google/genai'
import { createLogger } from '@sim/logger'
import type { StreamingExecution } from '@/executor/types'
import { executeGeminiRequest } from '@/providers/gemini/core'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
const logger = createLogger('GoogleProvider')
/**
* Google Gemini provider
*
* Uses the @google/genai SDK with API key authentication.
* Shares core execution logic with Vertex AI provider.
*/
export const googleProvider: ProviderConfig = {
id: 'google',
name: 'Google',
description: "Google's Gemini models",
version: '1.0.0',
models: getProviderModels('google'),
defaultModel: getProviderDefaultModel('google'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Google Gemini')
}
logger.info('Creating Google Gemini client', { model: request.model })
const ai = new GoogleGenAI({ apiKey: request.apiKey })
return executeGeminiRequest({
ai,
model: request.model,
request,
providerType: 'google',
})
},
}
+549
View File
@@ -0,0 +1,549 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
convertToGeminiFormat,
ensureStructResponse,
mapToThinkingBudget,
supportsDisablingGemini25Thinking,
} from '@/providers/google/utils'
import type { ProviderRequest } from '@/providers/types'
describe('mapToThinkingBudget', () => {
it('maps named levels to a within-range budget for gemini-2.5-pro (128-32768, cannot disable)', () => {
expect(mapToThinkingBudget('gemini-2.5-pro', 'low')).toBeGreaterThanOrEqual(128)
expect(mapToThinkingBudget('gemini-2.5-pro', 'high')).toBeLessThanOrEqual(32768)
})
it('maps named levels to a within-range budget for gemini-2.5-flash (0-24576)', () => {
expect(mapToThinkingBudget('gemini-2.5-flash', 'low')).toBeLessThanOrEqual(24576)
expect(mapToThinkingBudget('gemini-2.5-flash', 'high')).toBeLessThanOrEqual(24576)
})
it('maps named levels to a within-range budget for gemini-2.5-flash-lite (512-24576)', () => {
expect(mapToThinkingBudget('gemini-2.5-flash-lite', 'low')).toBeGreaterThanOrEqual(512)
expect(mapToThinkingBudget('gemini-2.5-flash-lite', 'high')).toBeLessThanOrEqual(24576)
})
it('strips the vertex/ prefix before looking up the model', () => {
expect(mapToThinkingBudget('vertex/gemini-2.5-flash', 'medium')).toBe(
mapToThinkingBudget('gemini-2.5-flash', 'medium')
)
})
it('falls back to dynamic budget (-1) for models with no explicit mapping', () => {
expect(mapToThinkingBudget('gemini-2.0-flash', 'medium')).toBe(-1)
})
it('falls back to the high budget for an unrecognized level on a mapped model', () => {
expect(mapToThinkingBudget('gemini-2.5-flash', 'unknown-level')).toBe(
mapToThinkingBudget('gemini-2.5-flash', 'high')
)
})
})
describe('supportsDisablingGemini25Thinking', () => {
it('returns true for gemini-2.5-flash and gemini-2.5-flash-lite', () => {
expect(supportsDisablingGemini25Thinking('gemini-2.5-flash')).toBe(true)
expect(supportsDisablingGemini25Thinking('gemini-2.5-flash-lite')).toBe(true)
})
it('returns false for gemini-2.5-pro, which cannot disable thinking', () => {
expect(supportsDisablingGemini25Thinking('gemini-2.5-pro')).toBe(false)
})
it('strips the vertex/ prefix before checking', () => {
expect(supportsDisablingGemini25Thinking('vertex/gemini-2.5-flash')).toBe(true)
expect(supportsDisablingGemini25Thinking('vertex/gemini-2.5-pro')).toBe(false)
})
it('returns false for models with no explicit mapping', () => {
expect(supportsDisablingGemini25Thinking('gemini-3.5-flash')).toBe(false)
})
})
describe('ensureStructResponse', () => {
describe('should return objects unchanged', () => {
it('should return plain object unchanged', () => {
const input = { key: 'value', nested: { a: 1 } }
const result = ensureStructResponse(input)
expect(result).toBe(input) // Same reference
expect(result).toEqual({ key: 'value', nested: { a: 1 } })
})
it('should return empty object unchanged', () => {
const input = {}
const result = ensureStructResponse(input)
expect(result).toBe(input)
expect(result).toEqual({})
})
})
describe('should wrap primitive values in { value: ... }', () => {
it('should wrap boolean true', () => {
const result = ensureStructResponse(true)
expect(result).toEqual({ value: true })
expect(typeof result).toBe('object')
})
it('should wrap boolean false', () => {
const result = ensureStructResponse(false)
expect(result).toEqual({ value: false })
expect(typeof result).toBe('object')
})
it('should wrap string', () => {
const result = ensureStructResponse('success')
expect(result).toEqual({ value: 'success' })
expect(typeof result).toBe('object')
})
it('should wrap empty string', () => {
const result = ensureStructResponse('')
expect(result).toEqual({ value: '' })
expect(typeof result).toBe('object')
})
it('should wrap number', () => {
const result = ensureStructResponse(42)
expect(result).toEqual({ value: 42 })
expect(typeof result).toBe('object')
})
it('should wrap zero', () => {
const result = ensureStructResponse(0)
expect(result).toEqual({ value: 0 })
expect(typeof result).toBe('object')
})
it('should wrap null', () => {
const result = ensureStructResponse(null)
expect(result).toEqual({ value: null })
expect(typeof result).toBe('object')
})
it('should wrap undefined', () => {
const result = ensureStructResponse(undefined)
expect(result).toEqual({ value: undefined })
expect(typeof result).toBe('object')
})
})
describe('should wrap arrays in { value: ... }', () => {
it('should wrap array of strings', () => {
const result = ensureStructResponse(['a', 'b', 'c'])
expect(result).toEqual({ value: ['a', 'b', 'c'] })
expect(typeof result).toBe('object')
expect(Array.isArray(result)).toBe(false)
})
it('should wrap array of objects', () => {
const result = ensureStructResponse([{ id: 1 }, { id: 2 }])
expect(result).toEqual({ value: [{ id: 1 }, { id: 2 }] })
expect(typeof result).toBe('object')
expect(Array.isArray(result)).toBe(false)
})
it('should wrap empty array', () => {
const result = ensureStructResponse([])
expect(result).toEqual({ value: [] })
expect(typeof result).toBe('object')
expect(Array.isArray(result)).toBe(false)
})
})
describe('edge cases', () => {
it('should handle nested objects correctly', () => {
const input = { a: { b: { c: 1 } }, d: [1, 2, 3] }
const result = ensureStructResponse(input)
expect(result).toBe(input) // Same reference, unchanged
})
it('should handle object with array property correctly', () => {
const input = { items: ['a', 'b'], count: 2 }
const result = ensureStructResponse(input)
expect(result).toBe(input) // Same reference, unchanged
})
})
})
describe('convertToGeminiFormat', () => {
it('should convert user message files to inline data parts', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{
role: 'user',
content: 'Analyze this image',
files: [
{
id: 'file-1',
key: 'workspace/ws-1/example.png',
name: 'example.png',
url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace',
size: 128,
type: 'image/png',
base64: 'iVBORw0KGgo=',
},
],
},
],
}
const result = convertToGeminiFormat(request)
expect(result.contents[0]).toEqual({
role: 'user',
parts: [
{ text: 'Analyze this image' },
{
inlineData: {
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
},
],
})
})
describe('tool message handling', () => {
it('should convert tool message with object response correctly', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city": "London"}' },
},
],
},
{
role: 'tool',
name: 'get_weather',
tool_call_id: 'call_123',
content: '{"temperature": 20, "condition": "sunny"}',
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
expect(toolResponseContent).toBeDefined()
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(functionResponse?.response).toEqual({ temperature: 20, condition: 'sunny' })
expect(typeof functionResponse?.response).toBe('object')
})
it('should wrap boolean true response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Check if user exists' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_456',
type: 'function',
function: { name: 'user_exists', arguments: '{"userId": "123"}' },
},
],
},
{
role: 'tool',
name: 'user_exists',
tool_call_id: 'call_456',
content: 'true', // Boolean true as JSON string
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
expect(toolResponseContent).toBeDefined()
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).not.toBe(true)
expect(functionResponse?.response).toEqual({ value: true })
})
it('should wrap boolean false response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Check if user exists' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_789',
type: 'function',
function: { name: 'user_exists', arguments: '{"userId": "999"}' },
},
],
},
{
role: 'tool',
name: 'user_exists',
tool_call_id: 'call_789',
content: 'false', // Boolean false as JSON string
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: false })
})
it('should wrap string response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get status' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_str',
type: 'function',
function: { name: 'get_status', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_status',
tool_call_id: 'call_str',
content: '"success"', // String as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: 'success' })
})
it('should wrap number response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get count' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_num',
type: 'function',
function: { name: 'get_count', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_count',
tool_call_id: 'call_num',
content: '42', // Number as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: 42 })
})
it('should wrap null response in an object for Gemini compatibility', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get data' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_null',
type: 'function',
function: { name: 'get_data', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_data',
tool_call_id: 'call_null',
content: 'null', // null as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: null })
})
it('should keep array response as-is since arrays are valid Struct values', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get items' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_arr',
type: 'function',
function: { name: 'get_items', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_items',
tool_call_id: 'call_arr',
content: '["item1", "item2"]', // Array as JSON
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ value: ['item1', 'item2'] })
})
it('should handle invalid JSON by wrapping in output object', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Get data' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_invalid',
type: 'function',
function: { name: 'get_data', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'get_data',
tool_call_id: 'call_invalid',
content: 'not valid json {',
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
expect(functionResponse?.response).toEqual({ output: 'not valid json {' })
})
it('should handle empty content by wrapping in output object', () => {
const request: ProviderRequest = {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Do something' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_empty',
type: 'function',
function: { name: 'do_action', arguments: '{}' },
},
],
},
{
role: 'tool',
name: 'do_action',
tool_call_id: 'call_empty',
content: '', // Empty content - falls back to default '{}'
},
],
}
const result = convertToGeminiFormat(request)
const toolResponseContent = result.contents.find(
(c) => c.parts?.[0] && 'functionResponse' in c.parts[0]
)
const functionResponse = (toolResponseContent?.parts?.[0] as { functionResponse?: unknown })
?.functionResponse as { response?: unknown }
expect(typeof functionResponse?.response).toBe('object')
// Empty string is not valid JSON, so it falls back to { output: "" }
expect(functionResponse?.response).toEqual({ output: '' })
})
})
})
+444
View File
@@ -0,0 +1,444 @@
import {
type Candidate,
type Content,
type FunctionCall,
FunctionCallingConfigMode,
type GenerateContentResponse,
type GenerateContentResponseUsageMetadata,
type Part,
type Schema,
type SchemaUnion,
ThinkingLevel,
type ToolConfig,
Type,
} from '@google/genai'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { buildGeminiMessageParts } from '@/providers/attachments'
import type { ProviderRequest } from '@/providers/types'
import { trackForcedToolUsage } from '@/providers/utils'
const logger = createLogger('GoogleUtils')
/**
* Ensures a value is a valid object for Gemini's functionResponse.response field.
* Gemini's API requires functionResponse.response to be a google.protobuf.Struct,
* which must be an object with string keys. Primitive values (boolean, string,
* number, null) and arrays are wrapped in { value: ... }.
*
* @param value - The value to ensure is a Struct-compatible object
* @returns A Record<string, unknown> suitable for functionResponse.response
*/
export function ensureStructResponse(value: unknown): Record<string, unknown> {
if (isRecordLike(value)) {
return value
}
return { value }
}
/**
* Usage metadata for Google Gemini responses
*/
export interface GeminiUsage {
promptTokenCount: number
candidatesTokenCount: number
totalTokenCount: number
}
/**
* Parsed function call from Gemini response
*/
interface ParsedFunctionCall {
name: string
args: Record<string, unknown>
}
/**
* Removes additionalProperties from a schema object (not supported by Gemini)
*/
export function cleanSchemaForGemini(schema: SchemaUnion): SchemaUnion {
if (schema === null || schema === undefined) return schema
if (typeof schema !== 'object') return schema
if (Array.isArray(schema)) {
return schema.map((item) => cleanSchemaForGemini(item))
}
const cleanedSchema: Record<string, unknown> = {}
const schemaObj = schema as Record<string, unknown>
for (const key in schemaObj) {
if (key === 'additionalProperties') continue
cleanedSchema[key] = cleanSchemaForGemini(schemaObj[key] as SchemaUnion)
}
return cleanedSchema
}
/**
* Extracts text content from a Gemini response candidate.
* Filters out thought parts (model reasoning) from the output.
*/
export function extractTextContent(candidate: Candidate | undefined): string {
if (!candidate?.content?.parts) return ''
const textParts = candidate.content.parts.filter(
(part): part is Part & { text: string } => Boolean(part.text) && part.thought !== true
)
if (textParts.length === 0) return ''
if (textParts.length === 1) return textParts[0].text
return textParts.map((part) => part.text).join('\n')
}
/**
* Extracts the first function call from a Gemini response candidate
*/
export function extractFunctionCall(candidate: Candidate | undefined): ParsedFunctionCall | null {
if (!candidate?.content?.parts) return null
for (const part of candidate.content.parts) {
if (part.functionCall) {
return {
name: part.functionCall.name ?? '',
args: (part.functionCall.args ?? {}) as Record<string, unknown>,
}
}
}
return null
}
/**
* Extracts the full Part containing the function call (preserves thoughtSignature)
* @deprecated Use extractAllFunctionCallParts for proper multi-tool handling
*/
export function extractFunctionCallPart(candidate: Candidate | undefined): Part | null {
if (!candidate?.content?.parts) return null
for (const part of candidate.content.parts) {
if (part.functionCall) {
return part
}
}
return null
}
/**
* Extracts ALL Parts containing function calls from a candidate.
* Gemini can return multiple function calls in a single response,
* and all should be executed before continuing the conversation.
*/
export function extractAllFunctionCallParts(candidate: Candidate | undefined): Part[] {
if (!candidate?.content?.parts) return []
return candidate.content.parts.filter((part) => part.functionCall)
}
/**
* Converts usage metadata from SDK response to our format.
* Per Gemini docs, total = promptTokenCount + candidatesTokenCount + toolUsePromptTokenCount + thoughtsTokenCount
* We include toolUsePromptTokenCount in input and thoughtsTokenCount in output for correct billing.
*/
export function convertUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata | undefined
): GeminiUsage {
const thoughtsTokenCount = usageMetadata?.thoughtsTokenCount ?? 0
const toolUsePromptTokenCount = usageMetadata?.toolUsePromptTokenCount ?? 0
const promptTokenCount = (usageMetadata?.promptTokenCount ?? 0) + toolUsePromptTokenCount
const candidatesTokenCount = (usageMetadata?.candidatesTokenCount ?? 0) + thoughtsTokenCount
return {
promptTokenCount,
candidatesTokenCount,
totalTokenCount: usageMetadata?.totalTokenCount ?? 0,
}
}
/**
* Tool definition for Gemini format
*/
export interface GeminiToolDef {
name: string
description: string
parameters: Schema
}
/**
* Converts OpenAI-style request format to Gemini format
*/
export function convertToGeminiFormat(
request: ProviderRequest,
providerId = 'google'
): {
contents: Content[]
tools: GeminiToolDef[] | undefined
systemInstruction: Content | undefined
} {
const contents: Content[] = []
let systemInstruction: Content | undefined
if (request.systemPrompt) {
systemInstruction = { parts: [{ text: request.systemPrompt }] }
}
if (request.context) {
contents.push({ role: 'user', parts: [{ text: request.context }] })
}
if (request.messages?.length) {
for (const message of request.messages) {
if (message.role === 'system') {
if (!systemInstruction) {
systemInstruction = { parts: [{ text: message.content ?? '' }] }
} else if (systemInstruction.parts?.[0] && 'text' in systemInstruction.parts[0]) {
systemInstruction.parts[0].text = `${systemInstruction.parts[0].text}\n${message.content}`
}
} else if (message.role === 'user' || message.role === 'assistant') {
const geminiRole = message.role === 'user' ? 'user' : 'model'
const parts = buildGeminiMessageParts(message.content, message.files, providerId) as Part[]
if (parts.length > 0) {
contents.push({ role: geminiRole, parts })
}
if (message.role === 'assistant' && message.tool_calls?.length) {
const functionCalls = message.tool_calls.map((toolCall) => ({
functionCall: {
name: toolCall.function?.name,
args: JSON.parse(toolCall.function?.arguments || '{}') as Record<string, unknown>,
},
}))
contents.push({ role: 'model', parts: functionCalls })
}
} else if (message.role === 'tool') {
if (!message.name) {
logger.warn('Tool message missing function name, skipping')
continue
}
let responseData: Record<string, unknown>
try {
const parsed = JSON.parse(message.content ?? '{}')
responseData = ensureStructResponse(parsed)
} catch {
responseData = { output: message.content }
}
contents.push({
role: 'user',
parts: [
{
functionResponse: {
id: message.tool_call_id,
name: message.name,
response: responseData,
},
},
],
})
}
}
}
const tools = request.tools?.map((tool): GeminiToolDef => {
const toolParameters = { ...(tool.parameters || {}) }
if (toolParameters.properties) {
const properties = { ...toolParameters.properties }
const required = toolParameters.required ? [...toolParameters.required] : []
// Remove default values from properties (not supported by Gemini)
for (const key in properties) {
const prop = properties[key] as Record<string, unknown>
if (prop.default !== undefined) {
const { default: _, ...cleanProp } = prop
properties[key] = cleanProp
}
}
const parameters: Schema = {
type: (toolParameters.type as Schema['type']) || Type.OBJECT,
properties: properties as Record<string, Schema>,
...(required.length > 0 ? { required } : {}),
}
return {
name: tool.id,
description: tool.description || `Execute the ${tool.id} function`,
parameters: cleanSchemaForGemini(parameters) as Schema,
}
}
return {
name: tool.id,
description: tool.description || `Execute the ${tool.id} function`,
parameters: cleanSchemaForGemini(toolParameters) as Schema,
}
})
return { contents, tools, systemInstruction }
}
/**
* Creates a ReadableStream from a Google Gemini streaming response
*/
export function createReadableStreamFromGeminiStream(
stream: AsyncGenerator<GenerateContentResponse>,
onComplete?: (content: string, usage: GeminiUsage) => void
): ReadableStream<Uint8Array> {
let fullContent = ''
let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 }
return new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
if (chunk.usageMetadata) {
usage = convertUsageMetadata(chunk.usageMetadata)
}
const text = chunk.text
if (text) {
fullContent += text
controller.enqueue(new TextEncoder().encode(text))
}
}
onComplete?.(fullContent, usage)
controller.close()
} catch (error) {
logger.error('Error reading Google Gemini stream', {
error: toError(error).message,
})
controller.error(error)
}
},
})
}
/**
* Maps string mode to FunctionCallingConfigMode enum
*/
function mapToFunctionCallingMode(mode: string): FunctionCallingConfigMode {
switch (mode) {
case 'AUTO':
return FunctionCallingConfigMode.AUTO
case 'ANY':
return FunctionCallingConfigMode.ANY
case 'NONE':
return FunctionCallingConfigMode.NONE
default:
return FunctionCallingConfigMode.AUTO
}
}
/**
* Maps string level to ThinkingLevel enum
*/
export function mapToThinkingLevel(level: string): ThinkingLevel {
switch (level.toLowerCase()) {
case 'minimal':
return ThinkingLevel.MINIMAL
case 'low':
return ThinkingLevel.LOW
case 'medium':
return ThinkingLevel.MEDIUM
case 'high':
return ThinkingLevel.HIGH
default:
return ThinkingLevel.HIGH
}
}
/**
* Per-model thinkingBudget ranges for Gemini 2.5-series models. Unlike Gemini 3.x, these
* models reject `thinkingLevel` entirely (Gemini API docs: "Gemini 2.5 series models don't
* support thinkingLevel; use thinkingBudget instead") and require a numeric token budget
* within each model's own documented range.
*/
const GEMINI_25_THINKING_BUDGETS: Record<string, Record<string, number>> = {
'gemini-2.5-pro': { low: 2048, medium: 8192, high: 32768 }, // valid range 128-32768, cannot disable
'gemini-2.5-flash': { low: 2048, medium: 8192, high: 24576 }, // valid range 0-24576
'gemini-2.5-flash-lite': { low: 1024, medium: 8192, high: 24576 }, // valid range 512-24576
}
/**
* Maps a named thinking level to a `thinkingBudget` token count for Gemini 2.5-series models.
* Falls back to -1 (dynamic/automatic budget) for any model not in the explicit table above,
* rather than guessing a number that could fall outside an unmapped model's valid range.
*/
export function mapToThinkingBudget(model: string, level: string): number {
const normalized = model.toLowerCase().replace(/^vertex\//, '')
const budgets = GEMINI_25_THINKING_BUDGETS[normalized]
if (!budgets) return -1
return budgets[level.toLowerCase()] ?? budgets.high
}
/**
* Gemini 2.5-series models that accept `thinkingBudget: 0` to explicitly disable thinking.
* gemini-2.5-pro cannot disable thinking at all (its documented budget floor is 128, not 0),
* so it's deliberately excluded here.
*/
const GEMINI_25_MODELS_SUPPORTING_DISABLE = new Set(['gemini-2.5-flash', 'gemini-2.5-flash-lite'])
/**
* Whether this Gemini 2.5-series model supports explicitly disabling thinking via budget=0.
* Omitting thinkingConfig entirely (the 'none' no-op path) falls back to the API's own
* dynamic default, which is ON for gemini-2.5-flash — not the same as actually disabling it.
*/
export function supportsDisablingGemini25Thinking(model: string): boolean {
const normalized = model.toLowerCase().replace(/^vertex\//, '')
return GEMINI_25_MODELS_SUPPORTING_DISABLE.has(normalized)
}
/**
* Result of checking forced tool usage
*/
export interface ForcedToolResult {
hasUsedForcedTool: boolean
usedForcedTools: string[]
nextToolConfig: ToolConfig | undefined
}
/**
* Checks for forced tool usage in Google Gemini responses
*/
export function checkForForcedToolUsage(
functionCalls: FunctionCall[] | undefined,
toolConfig: ToolConfig | undefined,
forcedTools: string[],
usedForcedTools: string[]
): ForcedToolResult | null {
if (!functionCalls?.length) return null
const adaptedToolCalls = functionCalls.map((fc) => ({
name: fc.name ?? '',
arguments: (fc.args ?? {}) as Record<string, unknown>,
}))
const result = trackForcedToolUsage(
adaptedToolCalls,
toolConfig,
logger,
'google',
forcedTools,
usedForcedTools
)
if (!result) return null
const nextToolConfig: ToolConfig | undefined = result.nextToolConfig?.functionCallingConfig?.mode
? {
functionCallingConfig: {
mode: mapToFunctionCallingMode(result.nextToolConfig.functionCallingConfig.mode),
allowedFunctionNames: result.nextToolConfig.functionCallingConfig.allowedFunctionNames,
},
}
: undefined
return {
hasUsedForcedTool: result.hasUsedForcedTool,
usedForcedTools: result.usedForcedTools,
nextToolConfig,
}
}
+508
View File
@@ -0,0 +1,508 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { Groq } from 'groq-sdk'
import type { StreamingExecution } from '@/executor/types'
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,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('GroqProvider')
export const groqProvider: ProviderConfig = {
id: 'groq',
name: 'Groq',
description: "Groq's LLM models with high-performance inference",
version: '1.0.0',
models: getProviderModels('groq'),
defaultModel: getProviderDefaultModel('groq'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Groq')
}
const groq = new Groq({ apiKey: request.apiKey })
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'groq')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model.replace('groq/', ''),
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.responseFormat) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
}
let originalToolChoice: any
let forcedTools: string[] = []
let hasFilteredTools = false
if (tools?.length) {
const preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai')
if (preparedTools.tools?.length) {
payload.tools = preparedTools.tools
payload.tool_choice = preparedTools.toolChoice || 'auto'
originalToolChoice = preparedTools.toolChoice
forcedTools = preparedTools.forcedTools || []
hasFilteredTools = preparedTools.hasFilteredTools
logger.info('Groq request configuration:', {
toolCount: preparedTools.tools.length,
toolChoice: payload.tool_choice,
forcedToolsCount: forcedTools.length,
hasFilteredTools,
model: request.model,
})
}
}
if (request.stream && (!tools || tools.length === 0)) {
logger.info('Using streaming response for Groq request (no tools)')
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
const streamResponse = await groq.chat.completions.create(
{
...payload,
stream: true,
},
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const initialCallTime = Date.now()
let currentResponse = await groq.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'groq' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
name: toolName,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
let usedForcedTools: string[] = []
if (typeof originalToolChoice === 'object' && forcedTools.length > 0) {
const toolTracking = trackForcedToolUsage(
currentResponse.choices[0]?.message?.tool_calls,
originalToolChoice,
logger,
'openai',
forcedTools,
usedForcedTools
)
usedForcedTools = toolTracking.usedForcedTools
const nextToolChoice = toolTracking.nextToolChoice
if (nextToolChoice && typeof nextToolChoice === 'object') {
payload.tool_choice = nextToolChoice
} else if (nextToolChoice === 'auto' || !nextToolChoice) {
payload.tool_choice = 'auto'
}
}
const nextPayload = {
...payload,
messages: currentMessages,
}
const nextModelStartTime = Date.now()
currentResponse = await groq.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'groq' }
)
}
} catch (error) {
logger.error('Error in Groq request:', { error })
}
if (request.stream) {
logger.info('Using streaming for final Groq response after tool processing')
const streamingPayload = {
...payload,
messages: currentMessages,
tool_choice: originalToolChoice || 'auto',
stream: true,
}
const streamResponse = await groq.chat.completions.create(
streamingPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Groq request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a Groq streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromGroqStream(
groqStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(groqStream, 'Groq', onComplete)
}
+221
View File
@@ -0,0 +1,221 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetApiKeyWithBYOK, mockExecuteRequest } = vi.hoisted(() => ({
mockGetApiKeyWithBYOK: vi.fn(),
mockExecuteRequest: vi.fn(),
}))
vi.mock('@/lib/api-key/byok', () => ({
getApiKeyWithBYOK: (...args: unknown[]) => mockGetApiKeyWithBYOK(...args),
}))
vi.mock('@/providers/registry', () => ({
getProviderExecutor: vi.fn().mockResolvedValue({
executeRequest: (...args: unknown[]) => mockExecuteRequest(...args),
}),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
getCostMultiplier: vi.fn(() => 1),
}))
import { executeProviderRequest } from '@/providers'
import type { ProviderResponse } from '@/providers/types'
const HOSTED_RATE_INPUT_COST = 0.340285
const HOSTED_RATE_OUTPUT_COST = 0.0387
const HOSTED_RATE_TOTAL_COST = HOSTED_RATE_INPUT_COST + HOSTED_RATE_OUTPUT_COST
function makeAnthropicResponse(): ProviderResponse {
// Mirrors the shape produced by Anthropic core for a real BYOK execution
// (gross hosted-rate cost was written into time-segment cost by the trace
// enricher even though the block-level cost should be zeroed for BYOK).
return {
content: 'hello',
model: 'claude-opus-4-6',
tokens: { input: 68057, output: 1548, total: 69605 },
cost: {
input: HOSTED_RATE_INPUT_COST,
output: HOSTED_RATE_OUTPUT_COST,
total: HOSTED_RATE_TOTAL_COST,
pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' },
},
timing: {
startTime: '2026-04-30T21:27:37.878Z',
endTime: '2026-04-30T21:28:19.836Z',
duration: 41958,
timeSegments: [
{
type: 'model',
name: 'claude-opus-4-6',
startTime: 1777584457878,
endTime: 1777584499836,
duration: 41958,
tokens: { input: 68057, output: 1548, total: 69605 },
cost: {
input: HOSTED_RATE_INPUT_COST,
output: HOSTED_RATE_OUTPUT_COST,
total: HOSTED_RATE_TOTAL_COST,
},
},
],
},
}
}
describe('executeProviderRequest — BYOK regression', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('zeroes block-level model cost for BYOK callers (existing behavior)', async () => {
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true })
mockExecuteRequest.mockResolvedValue(makeAnthropicResponse())
const result = (await executeProviderRequest('anthropic', {
model: 'claude-opus-4-6',
workspaceId: 'ws-1',
})) as ProviderResponse
expect(result.cost?.total).toBe(0)
expect(result.cost?.input).toBe(0)
expect(result.cost?.output).toBe(0)
})
it('zeroes per-segment model cost for BYOK callers so trace aggregation does not re-charge', async () => {
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true })
mockExecuteRequest.mockResolvedValue(makeAnthropicResponse())
const result = (await executeProviderRequest('anthropic', {
model: 'claude-opus-4-6',
workspaceId: 'ws-1',
})) as ProviderResponse
const segment = result.timing?.timeSegments?.[0]
expect(segment?.cost).toBeDefined()
expect(segment?.cost?.input).toBe(0)
expect(segment?.cost?.output).toBe(0)
expect(segment?.cost?.total).toBe(0)
// Tokens must be preserved so the UI still displays usage even when
// BYOK callers are not billed.
expect(segment?.tokens?.input).toBe(68057)
expect(segment?.tokens?.output).toBe(1548)
})
it('does not zero per-segment cost for non-BYOK hosted callers', async () => {
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false })
mockExecuteRequest.mockResolvedValue(makeAnthropicResponse())
const result = (await executeProviderRequest('anthropic', {
model: 'claude-opus-4-6',
workspaceId: 'ws-1',
})) as ProviderResponse
const segment = result.timing?.timeSegments?.[0]
expect(segment?.cost?.total).toBeCloseTo(HOSTED_RATE_TOTAL_COST, 6)
})
it('preserves tool segment cost (BYOK does not suppress tool charges)', async () => {
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true })
const responseWithToolSegment: ProviderResponse = {
content: 'hi',
model: 'claude-opus-4-6',
tokens: { input: 100, output: 50, total: 150 },
cost: {
input: 0.0005,
output: 0.00125,
total: 0.00175,
pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' },
},
timing: {
startTime: '2026-04-30T21:27:37.878Z',
endTime: '2026-04-30T21:27:38.000Z',
duration: 122,
timeSegments: [
{
type: 'model',
name: 'claude-opus-4-6',
startTime: 1777584457878,
endTime: 1777584457940,
duration: 62,
cost: { input: 0.0005, output: 0.00125, total: 0.00175 },
},
{
type: 'tool',
name: 'firecrawl_scrape',
startTime: 1777584457940,
endTime: 1777584458000,
duration: 60,
cost: { total: 0.01 },
},
],
},
}
mockExecuteRequest.mockResolvedValue(responseWithToolSegment)
const result = (await executeProviderRequest('anthropic', {
model: 'claude-opus-4-6',
workspaceId: 'ws-1',
})) as ProviderResponse
const [model, tool] = result.timing!.timeSegments!
expect(model.cost?.total).toBe(0)
expect(tool.type).toBe('tool')
expect(tool.cost?.total).toBe(0.01)
})
it('zeroes per-segment cost on streaming responses for BYOK callers', async () => {
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true })
const segments = [
{
type: 'model' as const,
name: 'claude-opus-4-6',
startTime: 1777584457878,
endTime: 1777584499836,
duration: 41958,
cost: {
input: HOSTED_RATE_INPUT_COST,
output: HOSTED_RATE_OUTPUT_COST,
total: HOSTED_RATE_TOTAL_COST,
},
},
]
const streamingResponse = {
stream: new ReadableStream(),
execution: {
success: true,
output: {
content: '',
model: 'claude-opus-4-6',
tokens: { input: 0, output: 0, total: 0 },
providerTiming: {
startTime: '2026-04-30T21:27:37.878Z',
endTime: '2026-04-30T21:28:19.836Z',
duration: 41958,
timeSegments: segments,
},
cost: {
input: HOSTED_RATE_INPUT_COST,
output: HOSTED_RATE_OUTPUT_COST,
total: HOSTED_RATE_TOTAL_COST,
},
},
logs: [],
},
}
mockExecuteRequest.mockResolvedValue(streamingResponse)
await executeProviderRequest('anthropic', {
model: 'claude-opus-4-6',
workspaceId: 'ws-1',
stream: true,
})
expect(segments[0].cost.total).toBe(0)
expect(segments[0].cost.input).toBe(0)
expect(segments[0].cost.output).toBe(0)
})
})
+266
View File
@@ -0,0 +1,266 @@
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
}
+296
View File
@@ -0,0 +1,296 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCreate, mockExecuteTool } = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockExecuteTool: vi.fn(),
}))
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(
class {
chat = { completions: { create: mockCreate } }
}
),
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 }))
vi.mock('@/lib/core/config/env', () => ({
env: { LITELLM_BASE_URL: 'http://litellm.test', LITELLM_API_KEY: '' },
}))
vi.mock('@/stores/providers', () => ({
useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) },
}))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: () => [],
getProviderDefaultModel: () => '',
}))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: (messages: unknown) => messages,
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/litellm/utils', () => ({
createReadableStreamFromLiteLLMStream: vi.fn(
() => new ReadableStream({ start: (c) => c.close() })
),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })),
sumToolCosts: vi.fn(() => 0),
prepareToolExecution: vi.fn((_tool, toolArgs) => ({
toolParams: toolArgs,
executionParams: toolArgs,
})),
prepareToolsWithUsageControl: vi.fn((tools) => ({
tools,
toolChoice: 'auto',
forcedTools: [],
hasFilteredTools: false,
})),
trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })),
enforceStrictSchema: vi.fn((schema) => ({ ...schema, additionalProperties: false })),
}))
import { litellmProvider } from '@/providers/litellm'
import { ProviderError } from '@/providers/types'
interface ChatOptions {
content?: string | null
toolCalls?: Array<{ id: string; function: { name: string; arguments: string } }>
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
}
function chat({ content = null, toolCalls, usage }: ChatOptions = {}) {
return {
choices: [
{
message: { content, tool_calls: toolCalls },
finish_reason: toolCalls ? 'tool_calls' : 'stop',
},
],
usage: usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
}
}
function tool(name: string) {
return { id: name, name, description: 'd', parameters: {} }
}
function run(request: Record<string, unknown>) {
return litellmProvider.executeRequest!({
model: 'litellm/llama-3',
messages: [{ role: 'user', content: 'Hi' }],
...request,
} as never) as Promise<any>
}
const firstPayload = () => mockCreate.mock.calls[0][0]
const lastPayload = () => mockCreate.mock.calls.at(-1)![0]
describe('litellmProvider.executeRequest', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockResolvedValue(chat({ content: 'hello' }))
mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } })
})
it('assembles messages, strips the model prefix, and maps params', async () => {
const result = await run({
systemPrompt: 'You are helpful.',
context: 'Some context',
temperature: 0.5,
maxTokens: 256,
})
const payload = firstPayload()
expect(payload.model).toBe('llama-3')
expect(payload.messages).toEqual([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Some context' },
{ role: 'user', content: 'Hi' },
])
expect(payload.temperature).toBe(0.5)
expect(payload.max_completion_tokens).toBe(256)
expect(result.content).toBe('hello')
expect(result.tokens).toEqual({ input: 5, output: 3, total: 8 })
})
it('forwards reasoning_effort only when set to a non-default value', async () => {
await run({ reasoningEffort: 'high' })
expect(firstPayload().reasoning_effort).toBe('high')
mockCreate.mockClear()
await run({ reasoningEffort: 'auto' })
expect(firstPayload().reasoning_effort).toBeUndefined()
mockCreate.mockClear()
await run({})
expect(firstPayload().reasoning_effort).toBeUndefined()
})
it('sanitizes the schema for strict response_format and passes it through otherwise', async () => {
await run({ responseFormat: { name: 'r', schema: { type: 'object', properties: {} } } })
let rf = firstPayload().response_format
expect(rf.type).toBe('json_schema')
expect(rf.json_schema.strict).toBe(true)
expect(rf.json_schema.schema.additionalProperties).toBe(false)
mockCreate.mockClear()
await run({
responseFormat: { name: 'r', schema: { type: 'object', properties: {} }, strict: false },
})
rf = firstPayload().response_format
expect(rf.json_schema.strict).toBe(false)
expect(rf.json_schema.schema.additionalProperties).toBeUndefined()
})
it('defers response_format past the tool loop and keeps tools on the final call', async () => {
mockCreate
.mockResolvedValueOnce(
chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{"q":1}' } }] })
)
.mockResolvedValueOnce(chat({ content: 'mid' }))
.mockResolvedValueOnce(chat({ content: '{"answer":1}' }))
const result = await run({
tools: [tool('known')],
reasoningEffort: 'high',
responseFormat: { name: 'r', schema: { type: 'object', properties: {} } },
})
expect(firstPayload().response_format).toBeUndefined()
expect(firstPayload().tools).toBeDefined()
const final = lastPayload()
expect(final.response_format.type).toBe('json_schema')
expect(final.tools).toBeDefined()
expect(final.tool_choice).toBe('none')
expect(final.parallel_tool_calls).toBe(false)
expect(final.reasoning_effort).toBe('high')
expect(result.content).toBe('{"answer":1}')
})
it('defers response_format into the final streaming call while keeping tools', async () => {
mockCreate
.mockResolvedValueOnce(
chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] })
)
.mockResolvedValueOnce(chat({ content: 'mid' }))
const result = await run({
stream: true,
tools: [tool('known')],
responseFormat: { name: 'r', schema: { type: 'object', properties: {} } },
})
const final = lastPayload()
expect(final.stream).toBe(true)
expect(final.response_format.type).toBe('json_schema')
expect(final.tools).toBeDefined()
expect(final.tool_choice).toBe('none')
expect(final.parallel_tool_calls).toBe(false)
expect(result.execution.isStreaming).toBe(true)
})
it('threads assistant tool_calls and a named tool response, and reports toolCalls', async () => {
mockCreate
.mockResolvedValueOnce(
chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] })
)
.mockResolvedValueOnce(chat({ content: 'done' }))
mockExecuteTool.mockResolvedValue({ success: true, output: { temp: 72 } })
const result = await run({ tools: [tool('known')] })
const followupMessages = mockCreate.mock.calls[1][0].messages
expect(followupMessages).toContainEqual({
role: 'assistant',
content: null,
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'known', arguments: '{}' } }],
})
expect(followupMessages).toContainEqual({
role: 'tool',
tool_call_id: 'c1',
name: 'known',
content: JSON.stringify({ temp: 72 }),
})
expect(result.toolCalls).toHaveLength(1)
expect(result.content).toBe('done')
})
it('emits a stub tool response for an unanswered tool_call_id', async () => {
mockCreate
.mockResolvedValueOnce(
chat({ toolCalls: [{ id: 'cX', function: { name: 'ghost', arguments: '{}' } }] })
)
.mockResolvedValueOnce(chat({ content: 'recovered' }))
await run({ tools: [tool('known')] })
expect(mockExecuteTool).not.toHaveBeenCalled()
const followupMessages = mockCreate.mock.calls[1][0].messages
const toolMsg = followupMessages.find((m: any) => m.role === 'tool' && m.tool_call_id === 'cX')
expect(toolMsg).toBeDefined()
expect(toolMsg.content).toContain('not available')
})
it('executes a tool with empty arguments without failing', async () => {
mockCreate
.mockResolvedValueOnce(
chat({ toolCalls: [{ id: 'c1', function: { name: 'ping', arguments: '' } }] })
)
.mockResolvedValueOnce(chat({ content: 'pong' }))
await run({ tools: [tool('ping')] })
expect(mockExecuteTool).toHaveBeenCalledTimes(1)
const toolMsg = mockCreate.mock.calls[1][0].messages.find((m: any) => m.role === 'tool')
expect(toolMsg.content).not.toContain('"error":true')
})
it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => {
mockCreate.mockResolvedValue(
chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] })
)
await run({ tools: [tool('known')] })
expect(mockCreate).toHaveBeenCalledTimes(1 + 20)
expect(mockExecuteTool).toHaveBeenCalledTimes(20)
})
it('returns a streaming execution when streaming without active tools', async () => {
const result = await run({ stream: true })
expect(firstPayload().stream).toBe(true)
expect(firstPayload().stream_options).toEqual({ include_usage: true })
expect(result.stream).toBeInstanceOf(ReadableStream)
expect(result.execution.isStreaming).toBe(true)
})
it('wraps API errors in a ProviderError using the error envelope message', async () => {
mockCreate.mockRejectedValue({
error: { message: 'rate limited', type: 'rate_limit_error', code: '429' },
})
await expect(run({})).rejects.toBeInstanceOf(ProviderError)
await expect(run({})).rejects.toThrow('rate limited')
})
})
+722
View File
@@ -0,0 +1,722 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import { env } from '@/lib/core/config/env'
import type { StreamingExecution } from '@/executor/types'
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,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
enforceStrictSchema,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers'
import { executeTool } from '@/tools'
const logger = createLogger('LiteLLMProvider')
const LITELLM_VERSION = '1.0.0'
export const litellmProvider: ProviderConfig = {
id: 'litellm',
name: 'LiteLLM',
description: 'LiteLLM proxy with OpenAI-compatible API',
version: LITELLM_VERSION,
models: getProviderModels('litellm'),
defaultModel: getProviderDefaultModel('litellm'),
async initialize() {
if (typeof window !== 'undefined') {
logger.info('Skipping LiteLLM initialization on client side to avoid CORS issues')
return
}
const baseUrl = (env.LITELLM_BASE_URL || '').replace(/\/$/, '')
if (!baseUrl) {
logger.info('LITELLM_BASE_URL not configured, skipping initialization')
return
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.LITELLM_API_KEY) {
headers.Authorization = `Bearer ${env.LITELLM_API_KEY}`
}
const response = await fetch(`${baseUrl}/v1/models`, { headers })
if (!response.ok) {
await response.text().catch(() => {})
useProvidersStore.getState().setProviderModels('litellm', [])
logger.warn('LiteLLM service is not available. The provider will be disabled.')
return
}
const { vllmUpstreamResponseSchema } = await import('@/lib/api/contracts/providers')
const data = vllmUpstreamResponseSchema.parse(await response.json())
const models = data.data.map((model) => `litellm/${model.id}`)
this.models = models
useProvidersStore.getState().setProviderModels('litellm', models)
logger.info(`Discovered ${models.length} LiteLLM model(s):`, { models })
} catch (error) {
logger.warn('LiteLLM model instantiation failed. The provider will be disabled.', {
error: getErrorMessage(error, 'Unknown error'),
})
}
},
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
logger.info('Preparing LiteLLM request', {
model: request.model,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const baseUrl = (env.LITELLM_BASE_URL || '').replace(/\/$/, '')
if (!baseUrl) {
throw new Error('LITELLM_BASE_URL is required for LiteLLM provider')
}
const apiKey = request.apiKey || env.LITELLM_API_KEY || 'empty'
const litellm = new OpenAI({
apiKey,
baseURL: `${baseUrl}/v1`,
})
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'litellm') as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model.replace(/^litellm\//, ''),
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') {
payload.reasoning_effort = request.reasoningEffort
}
const isStrictResponseFormat = request.responseFormat
? request.responseFormat.strict !== false
: false
const responseFormatPayload = request.responseFormat
? {
type: 'json_schema' as const,
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: isStrictResponseFormat
? enforceStrictSchema(request.responseFormat.schema || request.responseFormat)
: request.responseFormat.schema || request.responseFormat,
strict: isStrictResponseFormat,
},
}
: undefined
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'litellm')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
logger.info('LiteLLM request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: 'unknown',
model: payload.model,
})
}
}
const deferResponseFormat = !!responseFormatPayload && hasActiveTools
if (responseFormatPayload && !deferResponseFormat) {
payload.response_format = responseFormatPayload
logger.info('Added JSON schema response format to LiteLLM request')
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
logger.info('Using streaming response for LiteLLM request')
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await litellm.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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()
}
output.content = cleanContent
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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
const checkForForcedToolUsage = (
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }
) => {
if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) {
const toolCallsResponse = response.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
toolChoice,
logger,
'litellm',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
}
let currentResponse = await litellm.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
if (content && request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
checkForForcedToolUsage(currentResponse, originalToolChoice)
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
if (request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'litellm' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
logger.info(
`Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_TOOL_ITERATIONS})`
)
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = toolCall.function.arguments
? JSON.parse(toolCall.function.arguments)
: {}
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
const respondedToolCallIds = new Set<string>()
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
name: toolName,
content: JSON.stringify(resultContent),
})
respondedToolCallIds.add(toolCall.id)
}
for (const tc of toolCallsInResponse) {
if (respondedToolCallIds.has(tc.id)) continue
currentMessages.push({
role: 'tool',
tool_call_id: tc.id,
name: tc.function.name,
content: JSON.stringify({
error: true,
message: `Tool "${tc.function.name}" is not available`,
tool: tc.function.name,
}),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await litellm.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
checkForForcedToolUsage(currentResponse, nextPayload.tool_choice)
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
if (request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'litellm' }
)
}
if (request.stream) {
logger.info('Using streaming for final response after tool processing')
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: currentMessages,
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
if (deferResponseFormat && responseFormatPayload) {
streamingParams.response_format = responseFormatPayload
streamingParams.parallel_tool_calls = false
}
const streamResponse = await litellm.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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()
}
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)
output.cost = {
input: accumulatedCost.input + streamCost.input,
output: accumulatedCost.output + streamCost.output,
toolCost: tc || undefined,
total: accumulatedCost.total + streamCost.total + tc,
}
}),
})
return streamingResult
}
if (deferResponseFormat && responseFormatPayload) {
logger.info('Applying deferred JSON schema response format after tool processing')
const finalFormatStartTime = Date.now()
const finalPayload: any = {
...payload,
messages: currentMessages,
response_format: responseFormatPayload,
tool_choice: 'none',
parallel_tool_calls: false,
}
currentResponse = await litellm.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalFormatEndTime = Date.now()
timeSegments.push({
type: 'model',
name: request.model,
startTime: finalFormatStartTime,
endTime: finalFormatEndTime,
duration: finalFormatEndTime - finalFormatStartTime,
})
modelTime += finalFormatEndTime - finalFormatStartTime
const formattedContent = currentResponse.choices[0]?.message?.content
if (formattedContent) {
content = formattedContent.replace(/```json\n?|\n?```/g, '').trim()
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'litellm' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
let errorMessage = toError(error).message
let errorType: string | undefined
let errorCode: string | number | undefined
if (error && typeof error === 'object' && 'error' in error) {
const litellmError = error.error as any
if (litellmError && typeof litellmError === 'object') {
errorMessage = litellmError.message || errorMessage
errorType = litellmError.type
errorCode = litellmError.code
}
}
logger.error('Error in LiteLLM request:', {
error: errorMessage,
errorType,
errorCode,
duration: totalDuration,
})
throw new ProviderError(errorMessage, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a LiteLLM streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromLiteLLMStream(
litellmStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(litellmStream, 'LiteLLM', onComplete)
}
+647
View File
@@ -0,0 +1,647 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import { createReadableStreamFromMetaStream } from '@/providers/meta/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,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('MetaProvider')
const META_BASE_URL = 'https://api.meta.ai/v1'
export const metaProvider: ProviderConfig = {
id: 'meta',
name: 'Meta',
description: "Meta's Muse Spark models via the Meta Model API (OpenAI-compatible)",
version: '1.0.0',
models: getProviderModels('meta'),
defaultModel: getProviderDefaultModel('meta'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Meta')
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const meta = new OpenAI({
apiKey: request.apiKey,
baseURL: META_BASE_URL,
})
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'meta')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') {
payload.reasoning_effort = request.reasoningEffort
}
const responseFormatPayload = request.responseFormat
? {
type: 'json_schema' as const,
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
: undefined
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
hasActiveTools = true
// Meta's Chat Completions endpoint only supports tool_choice: "auto" —
// "none", "required", and named-function choices all return HTTP 400
// (confirmed via the official meta-model-cookbook tool-calling recipe).
// "auto" is already the endpoint default, so we never set the field; a
// forced tool choice degrades to auto rather than failing the request.
if (typeof toolChoice === 'object') {
logger.warn(
'Meta does not support forcing a specific tool; falling back to auto tool_choice',
{ requestedTool: toolChoice.function.name, model: request.model }
)
}
logger.info('Meta request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: 'unknown',
model: request.model,
})
}
}
// Structured output and tool calling cannot be sent together — OpenAI-compatible
// backends reject a request that carries both `response_format` and active
// `tools`/`tool_choice`. Defer the schema until after the tool loop completes.
const deferResponseFormat = !!responseFormatPayload && hasActiveTools
if (responseFormatPayload && !deferResponseFormat) {
payload.response_format = responseFormatPayload
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
logger.info('Using streaming response for Meta request (no tools)')
const streamResponse = await meta.chat.completions.create(
{
...payload,
stream: true,
stream_options: { include_usage: true },
},
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 }) =>
createReadableStreamFromMetaStream(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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await meta.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let hasUsedForcedTool = false
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
if (
typeof originalToolChoice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
originalToolChoice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'meta' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
// Every tool_call in the assistant message must be answered by a matching
// `tool` message, or the next request violates the OpenAI message contract.
// Emit an error result for an unknown tool rather than dropping it.
if (!tool) {
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: `Tool "${toolName}" is not available`,
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await meta.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
if (
typeof nextPayload.tool_choice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
nextPayload.tool_choice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'meta' }
)
}
} catch (error) {
logger.error('Error in Meta request:', { error })
throw error
}
if (request.stream) {
logger.info('Using streaming for final Meta response after tool processing')
// The tool loop is complete: this final pass only produces the textual answer.
// Meta rejects tool_choice: "none" (only "auto" is supported), so instead of
// forcing tool_choice we omit `tools` from this call entirely — with no tools
// declared, the model cannot emit a fresh tool call for the text-only adapter to drop.
const { tools: _omittedTools, ...streamingBasePayload } = payload
const streamingPayload: any = {
...streamingBasePayload,
messages: currentMessages,
stream: true,
stream_options: { include_usage: true },
}
if (deferResponseFormat && responseFormatPayload) {
streamingPayload.response_format = responseFormatPayload
}
const streamResponse = await meta.chat.completions.create(
streamingPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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 }) =>
createReadableStreamFromMetaStream(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
}
// Tools were active, so `response_format` was withheld from the loop. Make one final
// tool-free call to obtain the structured response now that the tool work is done.
// Meta rejects tool_choice: "none", so `tools` is dropped from this payload instead
// (see the streaming pass above for the same constraint).
if (deferResponseFormat && responseFormatPayload) {
logger.info('Applying deferred JSON schema response format after tool processing')
const finalFormatStartTime = Date.now()
const { tools: _omittedDeferredTools, ...deferredBasePayload } = payload
const finalPayload: any = {
...deferredBasePayload,
messages: currentMessages,
response_format: responseFormatPayload,
}
currentResponse = await meta.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalFormatEndTime = Date.now()
timeSegments.push({
type: 'model',
name: request.model,
startTime: finalFormatStartTime,
endTime: finalFormatEndTime,
duration: finalFormatEndTime - finalFormatStartTime,
})
modelTime += finalFormatEndTime - finalFormatStartTime
const formattedContent = currentResponse.choices[0]?.message?.content
if (formattedContent) {
content = formattedContent
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'meta' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Meta request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a Meta Model API streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromMetaStream(
metaStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(metaStream, 'Meta', onComplete)
}
+553
View File
@@ -0,0 +1,553 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import type { StreamingExecution } from '@/executor/types'
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,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('MistralProvider')
/**
* Mistral AI provider configuration
*/
export const mistralProvider: ProviderConfig = {
id: 'mistral',
name: 'Mistral AI',
description: "Mistral AI's language models",
version: '1.0.0',
models: getProviderModels('mistral'),
defaultModel: getProviderDefaultModel('mistral'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
logger.info('Preparing Mistral request', {
model: request.model,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
if (!request.apiKey) {
throw new Error('API key is required for Mistral AI')
}
const mistral = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://api.mistral.ai/v1',
})
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'mistral')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
if (request.responseFormat) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
}
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'mistral')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
logger.info('Mistral request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: toolChoice.type === 'tool'
? `force:${toolChoice.name}`
: toolChoice.type === 'any'
? `force:${toolChoice.any?.name || 'unknown'}`
: 'unknown',
model: request.model,
})
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.stream && (!tools || tools.length === 0)) {
logger.info('Using streaming response for Mistral request')
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
}
const streamResponse = await mistral.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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,
}
const costResult = calculateCost(
request.model,
usage.prompt_tokens,
usage.completion_tokens
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
const checkForForcedToolUsage = (
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any }
) => {
if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) {
const toolCallsResponse = response.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
toolChoice,
logger,
'mistral',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
}
let currentResponse = await mistral.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
checkForForcedToolUsage(currentResponse, originalToolChoice)
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'mistral' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
logger.info(
`Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_TOOL_ITERATIONS})`
)
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
name: toolName,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await mistral.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
checkForForcedToolUsage(currentResponse, nextPayload.tool_choice)
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'mistral' }
)
}
if (request.stream) {
logger.info('Using streaming for final response after tool processing')
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: currentMessages,
tool_choice: 'auto',
stream: true,
}
const streamResponse = await mistral.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 }) =>
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
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Mistral request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
/**
* Enriches the last model segment with per-iteration content from a Chat
* Completions response: assistant text, tool calls, finish reason, token usage.
*/
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a Mistral streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromMistralStream(
mistralStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(mistralStream, 'Mistral', onComplete)
}
+234
View File
@@ -0,0 +1,234 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
getBaseModelProviders,
getHostedModels,
orderModelIdsByReleaseDate,
PROVIDER_DEFINITIONS,
} from '@/providers/models'
/** Maps a lowercased model ID to its provider's index in the catalog. */
const PROVIDER_INDEX_BY_MODEL = new Map<string, number>()
/** Maps a lowercased model ID to its release time (ms), or null when undated. */
const RELEASE_TIME_BY_MODEL = new Map<string, number | null>()
for (const [providerIndex, provider] of Object.values(PROVIDER_DEFINITIONS).entries()) {
for (const model of provider.models) {
const id = model.id.toLowerCase()
PROVIDER_INDEX_BY_MODEL.set(id, providerIndex)
RELEASE_TIME_BY_MODEL.set(id, model.releaseDate ? Date.parse(model.releaseDate) : null)
}
}
describe('orderModelIdsByReleaseDate', () => {
it('keeps provider grouping order intact', () => {
const ordered = orderModelIdsByReleaseDate(Object.keys(getBaseModelProviders()))
let lastProviderIndex = -1
const seenProviders = new Set<number>()
for (const id of ordered) {
const providerIndex = PROVIDER_INDEX_BY_MODEL.get(id.toLowerCase())
expect(providerIndex).toBeDefined()
// A provider's models must form one contiguous run: once we leave a provider
// we never return to it.
if (providerIndex !== lastProviderIndex) {
expect(seenProviders.has(providerIndex as number)).toBe(false)
seenProviders.add(providerIndex as number)
lastProviderIndex = providerIndex as number
}
}
})
it('sorts models within a provider newest-first by release date', () => {
const ordered = orderModelIdsByReleaseDate(Object.keys(getBaseModelProviders()))
for (let i = 1; i < ordered.length; i++) {
const prev = ordered[i - 1].toLowerCase()
const curr = ordered[i].toLowerCase()
if (PROVIDER_INDEX_BY_MODEL.get(prev) !== PROVIDER_INDEX_BY_MODEL.get(curr)) continue
const prevTime = RELEASE_TIME_BY_MODEL.get(prev)
const currTime = RELEASE_TIME_BY_MODEL.get(curr)
// Dated models precede undated ones; among dated models, newer precedes older.
if (prevTime == null) {
expect(currTime).toBeNull()
} else if (currTime != null) {
expect(prevTime).toBeGreaterThanOrEqual(currTime)
}
}
})
it('preserves the cross-provider grouping order given in the input', () => {
// Pick the first model of two different providers and feed the second provider
// first; the helper must keep that provider's group ahead of the other.
const byProvider = new Map<number, string[]>()
for (const id of Object.keys(getBaseModelProviders())) {
const providerIndex = PROVIDER_INDEX_BY_MODEL.get(id.toLowerCase()) as number
const bucket = byProvider.get(providerIndex) ?? []
bucket.push(id)
byProvider.set(providerIndex, bucket)
}
const providerIndexes = [...byProvider.keys()]
expect(providerIndexes.length).toBeGreaterThanOrEqual(2)
const [firstProvider, secondProvider] = providerIndexes
const fromFirst = byProvider.get(firstProvider) as string[]
const fromSecond = byProvider.get(secondProvider) as string[]
// Input order intentionally leads with the second provider.
const input = [fromSecond[0], fromFirst[0]]
const ordered = orderModelIdsByReleaseDate(input)
expect(PROVIDER_INDEX_BY_MODEL.get(ordered[0].toLowerCase())).toBe(secondProvider)
expect(PROVIDER_INDEX_BY_MODEL.get(ordered[1].toLowerCase())).toBe(firstProvider)
})
it('places unknown model IDs last, preserving their input order', () => {
const known = Object.keys(getBaseModelProviders())[0]
const ordered = orderModelIdsByReleaseDate(['mystery-a', known, 'mystery-b'])
expect(ordered[0]).toBe(known)
expect(ordered.slice(1)).toEqual(['mystery-a', 'mystery-b'])
})
it('is case-insensitive when matching catalog IDs', () => {
const id = Object.keys(getBaseModelProviders())[0]
const ordered = orderModelIdsByReleaseDate([id.toUpperCase()])
expect(ordered).toEqual([id.toUpperCase()])
})
it('returns an empty array for empty input', () => {
expect(orderModelIdsByReleaseDate([])).toEqual([])
})
it('does not add or drop any IDs', () => {
const input = Object.keys(getBaseModelProviders())
const ordered = orderModelIdsByReleaseDate(input)
expect([...ordered].sort()).toEqual([...input].sort())
})
})
describe('sakana provider definition', () => {
const sakana = PROVIDER_DEFINITIONS.sakana
it('is registered with fugu as the default model', () => {
expect(sakana).toBeDefined()
expect(sakana.id).toBe('sakana')
expect(sakana.defaultModel).toBe('fugu')
expect(sakana.modelPatterns).toEqual([/^fugu/])
})
it('exposes fugu and fugu-ultra with a 1M context window', () => {
expect(sakana.models.map((m) => m.id)).toEqual(['fugu', 'fugu-ultra'])
for (const model of sakana.models) {
expect(model.contextWindow).toBe(1000000)
}
})
it('prices both models at the documented fugu-ultra ceiling', () => {
for (const model of sakana.models) {
expect(model.pricing.input).toBe(5)
expect(model.pricing.output).toBe(30)
expect(model.pricing.cachedInput).toBe(0.5)
}
})
it('routes bare fugu model IDs to the sakana provider', () => {
const baseModels = getBaseModelProviders()
expect(baseModels.fugu).toBe('sakana')
expect(baseModels['fugu-ultra']).toBe('sakana')
})
})
describe('nvidia provider definition', () => {
const nvidia = PROVIDER_DEFINITIONS.nvidia
const expectedModels = [
{ id: 'nvidia/llama-3.1-nemotron-70b-instruct', contextWindow: 128000 },
{ id: 'nvidia/llama-3.1-nemotron-ultra-253b-v1', contextWindow: 131072 },
{ id: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', contextWindow: 131072 },
{ id: 'nvidia/nemotron-3-nano-30b-a3b', contextWindow: 262144 },
{ id: 'nvidia/nemotron-3-super-120b-a12b', contextWindow: 1048576 },
{ id: 'nvidia/nemotron-3-ultra-550b-a55b', contextWindow: 1048576 },
]
it('is registered with the current-gen Super model as the default', () => {
expect(nvidia).toBeDefined()
expect(nvidia.id).toBe('nvidia')
expect(nvidia.defaultModel).toBe('nvidia/nemotron-3-super-120b-a12b')
expect(nvidia.modelPatterns).toEqual([/^nvidia\//])
})
it('exposes all six Nemotron models with the documented context windows', () => {
expect(nvidia.models.map((m) => m.id)).toEqual(expectedModels.map((m) => m.id))
for (const expected of expectedModels) {
const model = nvidia.models.find((m) => m.id === expected.id)
expect(model?.contextWindow).toBe(expected.contextWindow)
}
})
it('routes every nvidia model ID to the nvidia provider', () => {
const baseModels = getBaseModelProviders()
for (const expected of expectedModels) {
expect(baseModels[expected.id]).toBe('nvidia')
}
})
})
describe('zai provider definition', () => {
const zai = PROVIDER_DEFINITIONS.zai
const expectedModels = [
{ id: 'glm-5.2', contextWindow: 1000000 },
{ id: 'glm-5.1', contextWindow: 200000 },
{ id: 'glm-5', contextWindow: 200000 },
{ id: 'glm-5-turbo', contextWindow: 200000 },
{ id: 'glm-4.7', contextWindow: 200000 },
{ id: 'glm-4.7-flashx', contextWindow: 200000 },
{ id: 'glm-4.6', contextWindow: 200000 },
{ id: 'glm-4.5', contextWindow: 128000 },
{ id: 'glm-4.5-air', contextWindow: 128000 },
{ id: 'glm-4.5-x', contextWindow: 128000 },
{ id: 'glm-4.5-airx', contextWindow: 128000 },
{ id: 'glm-4-32b-0414-128k', contextWindow: 128000 },
]
it('is registered with a bare glm-4.6 as the default model', () => {
expect(zai).toBeDefined()
expect(zai.id).toBe('zai')
expect(zai.defaultModel).toBe('glm-4.6')
expect(zai.defaultModel.startsWith('zai/')).toBe(false)
// No fallback pattern — an unscoped `/^glm/` would overmatch unrelated self-hosted
// "glm-*" models and misroute them to Z.ai's hosted billing.
expect(zai.modelPatterns).toEqual([])
})
it('exposes every GLM model with the documented context window', () => {
expect(zai.models.map((m) => m.id)).toEqual(expectedModels.map((m) => m.id))
for (const expected of expectedModels) {
const model = zai.models.find((m) => m.id === expected.id)
expect(model?.contextWindow).toBe(expected.contextWindow)
}
})
it('routes every bare glm-* model ID to the zai provider', () => {
const baseModels = getBaseModelProviders()
for (const expected of expectedModels) {
expect(baseModels[expected.id]).toBe('zai')
}
})
it('is included in getHostedModels since Sim provides the Z.ai key server-side', () => {
expect(getHostedModels()).toContain('glm-4.6')
})
})
describe('xai provider definition', () => {
const xai = PROVIDER_DEFINITIONS.xai
it('is registered with grok-4.5 as the default model', () => {
expect(xai).toBeDefined()
expect(xai.id).toBe('xai')
expect(xai.defaultModel).toBe('grok-4.5')
})
it('is included in getHostedModels since Sim provides the xAI key server-side', () => {
expect(getHostedModels()).toContain('grok-4.5')
})
})
File diff suppressed because it is too large Load Diff
+626
View File
@@ -0,0 +1,626 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { createReadableStreamFromNvidiaStream } from '@/providers/nvidia/utils'
import { createStreamingExecution } from '@/providers/streaming-execution'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import type {
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('NvidiaProvider')
const NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1'
/**
* NVIDIA NIM's Nemotron models via an OpenAI-compatible chat-completions API
* (`integrate.api.nvidia.com`). Output length is capped via `max_tokens`, not OpenAI's newer
* `max_completion_tokens`, which vLLM-served NIM models don't recognize.
*/
export const nvidiaProvider: ProviderConfig = {
id: 'nvidia',
name: 'NVIDIA NIM',
description: "NVIDIA's Nemotron models via NIM's OpenAI-compatible API",
version: '1.0.0',
models: getProviderModels('nvidia'),
defaultModel: getProviderDefaultModel('nvidia'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for NVIDIA NIM')
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const nvidia = new OpenAI({
apiKey: request.apiKey,
baseURL: NVIDIA_BASE_URL,
})
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'nvidia')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
const responseFormatPayload = request.responseFormat
? {
type: 'json_schema' as const,
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
: undefined
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
logger.info('NVIDIA NIM request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: 'unknown',
model: request.model,
})
}
}
const deferResponseFormat = !!responseFormatPayload && hasActiveTools
if (responseFormatPayload && !deferResponseFormat) {
payload.response_format = responseFormatPayload
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
logger.info('Using streaming response for NVIDIA NIM request (no tools)')
const streamResponse = await nvidia.chat.completions.create(
{
...payload,
stream: true,
stream_options: { include_usage: true },
},
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 }) =>
createReadableStreamFromNvidiaStream(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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await nvidia.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let hasUsedForcedTool = false
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
if (
typeof originalToolChoice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
originalToolChoice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'nvidia' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) {
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: `Tool "${toolName}" is not available`,
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await nvidia.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
if (
typeof nextPayload.tool_choice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
nextPayload.tool_choice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'nvidia' }
)
}
} catch (error) {
logger.error('Error in NVIDIA NIM request:', { error })
throw error
}
if (request.stream) {
logger.info('Using streaming for final NVIDIA NIM response after tool processing')
const streamingPayload: any = {
...payload,
messages: currentMessages,
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
if (deferResponseFormat && responseFormatPayload) {
streamingPayload.response_format = responseFormatPayload
streamingPayload.parallel_tool_calls = false
}
const streamResponse = await nvidia.chat.completions.create(
streamingPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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 }) =>
createReadableStreamFromNvidiaStream(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
}
if (deferResponseFormat && responseFormatPayload) {
logger.info('Applying deferred JSON schema response format after tool processing')
const finalFormatStartTime = Date.now()
const finalPayload: any = {
...payload,
messages: currentMessages,
response_format: responseFormatPayload,
tool_choice: 'none',
parallel_tool_calls: false,
}
currentResponse = await nvidia.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalFormatEndTime = Date.now()
timeSegments.push({
type: 'model',
name: request.model,
startTime: finalFormatStartTime,
endTime: finalFormatEndTime,
duration: finalFormatEndTime - finalFormatStartTime,
})
modelTime += finalFormatEndTime - finalFormatStartTime
const formattedContent = currentResponse.choices[0]?.message?.content
if (formattedContent) {
content = formattedContent
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'nvidia' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in NVIDIA NIM request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from an NVIDIA NIM streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromNvidiaStream(
nvidiaStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(nvidiaStream, 'NVIDIA', onComplete)
}
@@ -0,0 +1,369 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
type StreamUsage = { prompt_tokens: number; completion_tokens: number; total_tokens: number }
const { mockCreate, mockExecuteTool, streamOnComplete, MockAPIError } = vi.hoisted(() => {
class MockAPIError extends Error {
status?: number
code?: string | null
type?: string
constructor(message: string, opts: { status?: number; code?: string; type?: string } = {}) {
super(message)
this.name = 'APIError'
this.status = opts.status
this.code = opts.code
this.type = opts.type
}
}
return {
mockCreate: vi.fn(),
mockExecuteTool: vi.fn(),
streamOnComplete: {
current: undefined as undefined | ((content: string, usage: StreamUsage) => void),
},
MockAPIError,
}
})
const mockOpenAIConstructor = vi.hoisted(() => vi.fn())
vi.mock('openai', () => {
const OpenAI = vi.fn().mockImplementation(
class {
chat = { completions: { create: mockCreate } }
constructor(opts: unknown) {
mockOpenAIConstructor(opts)
}
}
)
;(OpenAI as unknown as { APIError: typeof MockAPIError }).APIError = MockAPIError
return { default: OpenAI }
})
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 }))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn().mockReturnValue([]),
getProviderDefaultModel: vi.fn().mockReturnValue(''),
}))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: (messages: unknown) => messages,
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/ollama-cloud/utils', () => ({
createReadableStreamFromOllamaCloudStream: (
_stream: unknown,
onComplete: (content: string, usage: StreamUsage) => void
) => {
streamOnComplete.current = onComplete
return 'OLLAMA_CLOUD_STREAM'
},
}))
vi.mock('@/providers/utils', () => ({
calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }),
generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS',
prepareToolExecution: (_tool: unknown, args: Record<string, unknown>) => ({
toolParams: args,
executionParams: args,
}),
sumToolCosts: () => 0,
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
import { ollamaCloudProvider } from '@/providers/ollama-cloud'
import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types'
interface StreamingResult {
stream: string
execution: {
output: {
content: string
model: string
tokens: { input: number; output: number; total: number }
toolCalls?: { list: unknown[]; count: number }
cost?: { input: number; output: number; total: number }
}
}
}
type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } }
function completion(
opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {}
) {
return {
choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }],
usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
}
}
function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): ProviderToolConfig {
return {
id,
name: id,
description: `${id} tool`,
params: {},
parameters: { type: 'object', properties: {}, required: [] },
...(usageControl ? { usageControl } : {}),
}
}
const baseRequest: ProviderRequest = {
model: 'ollama-cloud/gpt-oss:120b',
messages: [{ role: 'user', content: 'hi' }],
apiKey: 'oc-test-key',
}
describe('ollamaCloudProvider.executeRequest', () => {
beforeEach(() => {
vi.clearAllMocks()
streamOnComplete.current = undefined
mockCreate.mockResolvedValue(completion({ content: 'hello' }))
mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } })
})
it('throws when the API key is missing (BYOK is required)', async () => {
await expect(
ollamaCloudProvider.executeRequest({ ...baseRequest, apiKey: undefined })
).rejects.toThrow('API key is required for Ollama Cloud')
})
it('builds the OpenAI client with the cloud base URL and the user key', async () => {
await ollamaCloudProvider.executeRequest(baseRequest)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'oc-test-key',
baseURL: 'https://ollama.com/v1',
})
)
})
it('strips the ollama-cloud/ prefix before calling the API and reports the stripped model id', async () => {
const result = (await ollamaCloudProvider.executeRequest(baseRequest)) as ProviderResponse
expect(mockCreate.mock.calls[0][0].model).toBe('gpt-oss:120b')
expect(result).toMatchObject({ content: 'hello', model: 'gpt-oss:120b' })
})
it('assembles system, context, then history in order and forwards params', async () => {
await ollamaCloudProvider.executeRequest({
...baseRequest,
systemPrompt: 'be nice',
context: 'ctx',
temperature: 0.5,
maxTokens: 128,
})
const payload = mockCreate.mock.calls[0][0]
expect(payload.messages).toEqual([
{ role: 'system', content: 'be nice' },
{ role: 'user', content: 'ctx' },
{ role: 'user', content: 'hi' },
])
expect(payload.temperature).toBe(0.5)
expect(payload.max_tokens).toBe(128)
})
it('returns content verbatim (keeps ```json fences) when no responseFormat', async () => {
const fenced = '```json\n{"a":1}\n```'
mockCreate.mockResolvedValue(completion({ content: fenced }))
const result = (await ollamaCloudProvider.executeRequest(baseRequest)) as ProviderResponse
expect(result.content).toBe(fenced)
})
it('strips ```json fences and requests JSON mode with schema instructions when responseFormat is set', async () => {
mockCreate.mockResolvedValue(completion({ content: '```json\n{"a":1}\n```' }))
const result = (await ollamaCloudProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'r', schema: { type: 'object' }, strict: true },
})) as ProviderResponse
expect(result.content).toBe('{"a":1}')
const payload = mockCreate.mock.calls[0][0]
expect(payload.response_format).toEqual({ type: 'json_object' })
expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'SCHEMA_INSTRUCTIONS' })
})
it('runs the tool loop: parses string args, feeds results back, then terminates', async () => {
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'done' }))
const result = (await ollamaCloudProvider.executeRequest({
...baseRequest,
tools: [makeTool('mytool')],
})) as ProviderResponse
expect(mockExecuteTool).toHaveBeenCalledWith('mytool', { x: 1 }, expect.anything())
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(result.content).toBe('done')
expect(result.toolCalls).toEqual([
expect.objectContaining({ name: 'mytool', success: true, arguments: { x: 1 } }),
])
expect(result.toolResults).toEqual([{ ok: true }])
const followUp = mockCreate.mock.calls[1][0].messages
expect(followUp).toContainEqual(
expect.objectContaining({
role: 'assistant',
content: null,
tool_calls: [
expect.objectContaining({
id: 'call_1',
function: { name: 'mytool', arguments: '{"x":1}' },
}),
],
})
)
expect(followUp).toContainEqual({
role: 'tool',
tool_call_id: 'call_1',
content: JSON.stringify({ ok: true }),
})
})
it('records a failed tool result without aborting the loop', async () => {
mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' })
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'recovered' }))
const result = (await ollamaCloudProvider.executeRequest({
...baseRequest,
tools: [makeTool('mytool')],
})) as ProviderResponse
expect(result.content).toBe('recovered')
expect(result.toolCalls?.[0]).toMatchObject({ name: 'mytool', success: false })
const toolMsg = mockCreate.mock.calls[1][0].messages.find(
(m: { role: string }) => m.role === 'tool'
)
expect(JSON.parse(toolMsg.content)).toMatchObject({ error: true, message: 'boom' })
})
it('executes parallel tool calls from a single response', async () => {
mockExecuteTool
.mockResolvedValueOnce({ success: true, output: { from: 'a' } })
.mockResolvedValueOnce({ success: true, output: { from: 'b' } })
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_a', type: 'function', function: { name: 'a', arguments: '{}' } },
{ id: 'call_b', type: 'function', function: { name: 'b', arguments: '{}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'summary' }))
const result = (await ollamaCloudProvider.executeRequest({
...baseRequest,
tools: [makeTool('a'), makeTool('b')],
})) as ProviderResponse
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
expect(result.toolCalls?.map((c) => c.name)).toEqual(['a', 'b'])
})
it('filters out tools with usageControl "none"', async () => {
await ollamaCloudProvider.executeRequest({
...baseRequest,
tools: [makeTool('keep'), makeTool('drop', 'none')],
})
const sent = mockCreate.mock.calls[0][0].tools
expect(sent.map((t: { function: { name: string } }) => t.function.name)).toEqual(['keep'])
})
it('never forces tools (Ollama Cloud ignores tool_choice) and keeps "auto"', async () => {
await ollamaCloudProvider.executeRequest({
...baseRequest,
tools: [makeTool('forced', 'force')],
})
const payload = mockCreate.mock.calls[0][0]
expect(payload.tool_choice).toBe('auto')
expect(payload.tools.map((t: { function: { name: string } }) => t.function.name)).toEqual([
'forced',
])
})
it('surfaces an OpenAI APIError message through ProviderError', async () => {
mockCreate.mockRejectedValue(
new MockAPIError('model not found', {
status: 404,
code: 'not_found',
type: 'invalid_request_error',
})
)
await expect(ollamaCloudProvider.executeRequest(baseRequest)).rejects.toThrow('model not found')
})
it('streams content and usage when no tools are used', async () => {
const result = (await ollamaCloudProvider.executeRequest({
...baseRequest,
stream: true,
})) as unknown as StreamingResult
expect(result.stream).toBe('OLLAMA_CLOUD_STREAM')
expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true })
expect(result.execution.output.model).toBe('gpt-oss:120b')
streamOnComplete.current?.('streamed text', {
prompt_tokens: 4,
completion_tokens: 6,
total_tokens: 10,
})
expect(result.execution.output.content).toBe('streamed text')
expect(result.execution.output.tokens).toMatchObject({ input: 4, output: 6, total: 10 })
})
it('streams the final response after a tool loop and removes tools/tool_choice', async () => {
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'intermediate' }))
const result = (await ollamaCloudProvider.executeRequest({
...baseRequest,
stream: true,
tools: [makeTool('mytool')],
})) as unknown as StreamingResult
expect(result.stream).toBe('OLLAMA_CLOUD_STREAM')
expect(mockExecuteTool).toHaveBeenCalledTimes(1)
const finalCall = mockCreate.mock.calls[2][0]
expect(finalCall.tools).toBeUndefined()
expect(finalCall.tool_choice).toBeUndefined()
streamOnComplete.current?.('final answer', {
prompt_tokens: 2,
completion_tokens: 4,
total_tokens: 6,
})
expect(result.execution.output.content).toBe('final answer')
expect(result.execution.output.toolCalls).toMatchObject({ count: 1 })
})
})
+47
View File
@@ -0,0 +1,47 @@
import { createLogger } from '@sim/logger'
import OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { executeOllamaProviderRequest } from '@/providers/ollama/core'
import { createReadableStreamFromOllamaCloudStream } from '@/providers/ollama-cloud/utils'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
const logger = createLogger('OllamaCloudProvider')
/** Ollama Cloud OpenAI-compatible endpoint. BYOK only — Sim never hosts a key or bills usage. */
const OLLAMA_CLOUD_BASE_URL = 'https://ollama.com/v1'
export const ollamaCloudProvider: ProviderConfig = {
id: 'ollama-cloud',
name: 'Ollama Cloud',
description: 'Hosted open-source models via Ollama Cloud (bring your own key)',
version: '1.0.0',
models: getProviderModels('ollama-cloud'),
defaultModel: getProviderDefaultModel('ollama-cloud'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
const apiKey = request.apiKey
if (!apiKey) {
throw new Error('API key is required for Ollama Cloud')
}
const requestedModel = request.model.replace(/^ollama-cloud\//, '')
return executeOllamaProviderRequest(
{ ...request, model: requestedModel },
{
providerId: 'ollama-cloud',
providerLabel: 'Ollama Cloud',
createClient: () =>
new OpenAI({
apiKey,
baseURL: OLLAMA_CLOUD_BASE_URL,
}),
createStream: createReadableStreamFromOllamaCloudStream,
logger,
}
)
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from an Ollama Cloud streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromOllamaCloudStream(
ollamaStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(ollamaStream, 'Ollama Cloud', onComplete)
}
+620
View File
@@ -0,0 +1,620 @@
import type { Logger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import OpenAI from 'openai'
import type {
ChatCompletionChunk,
ChatCompletionCreateParamsStreaming,
} from 'openai/resources/chat/completions'
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'
import {
calculateCost,
generateSchemaInstructions,
prepareToolExecution,
sumToolCosts,
} from '@/providers/utils'
import { executeTool } from '@/tools'
/**
* Ollama enforces JSON mode (`json_object`) but ignores `json_schema`, so
* structured outputs use JSON mode with the schema described in-prompt. Mutates
* `payload.response_format` and returns the messages with instructions appended.
*/
function applyJsonResponseFormat(
payload: { response_format?: unknown },
messages: Message[],
responseFormat: NonNullable<ProviderRequest['responseFormat']>
): Message[] {
payload.response_format = { type: 'json_object' }
const schema = responseFormat.schema || responseFormat
return [
...messages,
{ role: 'user', content: generateSchemaInstructions(schema, responseFormat.name) },
]
}
/**
* Per-provider hooks for the shared Ollama execution logic. The self-hosted
* `ollama` and hosted `ollama-cloud` providers differ only in client
* construction and labels; both pass those in here.
*/
export interface OllamaCoreConfig {
/** Provider id used for trace enrichment (`ollama`, `ollama-cloud`). */
providerId: string
/** Human-readable label used in log messages. */
providerLabel: string
/** Builds the OpenAI-compatible client (base URL + credentials per provider). */
createClient: () => OpenAI
createStream: (
stream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
) => ReadableStream<Uint8Array>
logger: Logger
}
/**
* Shared execution logic for the Ollama-family providers, which speak the same
* OpenAI-compatible Ollama API. Ollama ignores `tool_choice`, so tools are sent
* as `tool_choice: 'auto'` (forced tools degrade to auto) and the final post-tool
* call drops tools entirely rather than relying on `tool_choice: 'none'`.
*/
export async function executeOllamaProviderRequest(
request: ProviderRequest,
config: OllamaCoreConfig
): Promise<ProviderResponse | StreamingExecution> {
const { providerId, providerLabel, logger } = config
logger.info(`Preparing ${providerLabel} request`, {
model: request.model,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const ollama = config.createClient()
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, providerId) as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
let hasActiveTools = false
if (tools?.length) {
const filteredTools = tools.filter((tool) => {
const toolId = tool.function?.name
const toolConfig = request.tools?.find((t) => t.id === toolId)
return toolConfig?.usageControl !== 'none'
})
const hasForcedTools = tools.some((tool) => {
const toolId = tool.function?.name
const toolConfig = request.tools?.find((t) => t.id === toolId)
return toolConfig?.usageControl === 'force'
})
if (hasForcedTools) {
logger.warn(
`${providerLabel} does not support forced tool selection (tool_choice parameter is ignored). ` +
'Tools marked with usageControl="force" will behave as "auto" instead.'
)
}
if (filteredTools?.length) {
payload.tools = filteredTools
payload.tool_choice = 'auto'
hasActiveTools = true
logger.info(`${providerLabel} request configuration:`, {
toolCount: filteredTools.length,
toolChoice: 'auto',
forcedToolsIgnored: hasForcedTools,
model: request.model,
})
}
}
// With tools, defer structured output to the final call so JSON mode doesn't preempt tool use.
if (request.responseFormat && !hasActiveTools) {
payload.messages = applyJsonResponseFormat(payload, payload.messages, request.responseFormat)
logger.info(`Added JSON response format to ${providerLabel} request`)
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
logger.info(`Using streaming response for ${providerLabel} request`)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await ollama.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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()
}
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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
let currentResponse = await ollama.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
if (content && request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '')
content = content.trim()
}
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
if (request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{
model: request.model,
provider: providerId,
}
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
logger.info(
`Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_TOOL_ITERATIONS})`
)
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
const nextModelStartTime = Date.now()
currentResponse = await ollama.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
if (request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: providerId }
)
}
if (request.stream) {
logger.info(`Using streaming for final ${providerLabel} response after tool processing`)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
const { tools: _tools, tool_choice: _toolChoice, ...streamPayload } = payload
const finalMessages = request.responseFormat
? applyJsonResponseFormat(streamPayload, currentMessages, request.responseFormat)
: currentMessages
const streamingParams: ChatCompletionCreateParamsStreaming = {
...streamPayload,
messages: finalMessages,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await ollama.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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()
}
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
}
// Deferred structured output: one final JSON-mode call now that tools have run.
if (request.responseFormat && hasActiveTools) {
const finalPayload: any = { model: payload.model }
if (payload.temperature !== undefined) finalPayload.temperature = payload.temperature
if (payload.max_tokens !== undefined) finalPayload.max_tokens = payload.max_tokens
finalPayload.messages = applyJsonResponseFormat(
finalPayload,
currentMessages,
request.responseFormat
)
const finalStartTime = Date.now()
const finalResponse = await ollama.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalEndTime = Date.now()
timeSegments.push({
type: 'model',
name: 'Final structured response',
startTime: finalStartTime,
endTime: finalEndTime,
duration: finalEndTime - finalStartTime,
})
modelTime += finalEndTime - finalStartTime
if (finalResponse.choices[0]?.message?.content) {
content = finalResponse.choices[0].message.content.replace(/```json\n?|\n?```/g, '').trim()
}
if (finalResponse.usage) {
tokens.input += finalResponse.usage.prompt_tokens || 0
tokens.output += finalResponse.usage.completion_tokens || 0
tokens.total += finalResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
finalResponse,
finalResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: providerId }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
let errorMessage = getErrorMessage(error, 'Unknown error')
let errorType: string | undefined
let errorCode: string | undefined
let status: number | undefined
if (error instanceof OpenAI.APIError) {
errorMessage = error.message
errorType = error.type
errorCode = error.code ?? undefined
status = error.status
}
logger.error(`Error in ${providerLabel} request:`, {
error: errorMessage,
errorType,
errorCode,
status,
duration: totalDuration,
})
throw new ProviderError(errorMessage, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
}
+384
View File
@@ -0,0 +1,384 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
type StreamUsage = { prompt_tokens: number; completion_tokens: number; total_tokens: number }
const { mockCreate, mockExecuteTool, streamOnComplete, MockAPIError } = vi.hoisted(() => {
class MockAPIError extends Error {
status?: number
code?: string | null
type?: string
constructor(message: string, opts: { status?: number; code?: string; type?: string } = {}) {
super(message)
this.name = 'APIError'
this.status = opts.status
this.code = opts.code
this.type = opts.type
}
}
return {
mockCreate: vi.fn(),
mockExecuteTool: vi.fn(),
streamOnComplete: {
current: undefined as undefined | ((content: string, usage: StreamUsage) => void),
},
MockAPIError,
}
})
vi.mock('openai', () => {
const OpenAI = vi.fn().mockImplementation(
class {
chat = { completions: { create: mockCreate } }
}
)
;(OpenAI as unknown as { APIError: typeof MockAPIError }).APIError = MockAPIError
return { default: OpenAI }
})
vi.mock('@/lib/core/utils/urls', () => ({ getOllamaUrl: () => 'http://localhost:11434' }))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 }))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: (messages: unknown) => messages,
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/ollama/utils', () => ({
createReadableStreamFromOllamaStream: (
_stream: unknown,
onComplete: (content: string, usage: StreamUsage) => void
) => {
streamOnComplete.current = onComplete
return 'OLLAMA_STREAM'
},
}))
vi.mock('@/providers/utils', () => ({
calculateCost: () => ({ input: 0, output: 0, total: 0, pricing: null }),
generateSchemaInstructions: () => 'SCHEMA_INSTRUCTIONS',
prepareToolExecution: (_tool: unknown, args: Record<string, unknown>) => ({
toolParams: args,
executionParams: args,
}),
sumToolCosts: () => 0,
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/stores/providers', () => ({
useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) },
}))
import { ollamaProvider } from '@/providers/ollama'
import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types'
interface StreamingResult {
stream: string
execution: {
output: {
content: string
tokens: { input: number; output: number; total: number }
toolCalls?: { list: unknown[]; count: number }
}
}
}
type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } }
function completion(
opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {}
) {
return {
choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }],
usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
}
}
function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): ProviderToolConfig {
return {
id,
name: id,
description: `${id} tool`,
params: {},
parameters: { type: 'object', properties: {}, required: [] },
...(usageControl ? { usageControl } : {}),
}
}
const baseRequest: ProviderRequest = {
model: 'llama3.2',
messages: [{ role: 'user', content: 'hi' }],
}
describe('ollamaProvider.executeRequest', () => {
beforeEach(() => {
vi.clearAllMocks()
streamOnComplete.current = undefined
mockCreate.mockResolvedValue(completion({ content: 'hello' }))
mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } })
})
it('assembles system, context, then history in order and forwards params', async () => {
const result = (await ollamaProvider.executeRequest({
...baseRequest,
systemPrompt: 'be nice',
context: 'ctx',
temperature: 0.5,
maxTokens: 128,
})) as ProviderResponse
expect(result).toMatchObject({ content: 'hello', model: 'llama3.2' })
const payload = mockCreate.mock.calls[0][0]
expect(payload.messages).toEqual([
{ role: 'system', content: 'be nice' },
{ role: 'user', content: 'ctx' },
{ role: 'user', content: 'hi' },
])
expect(payload.model).toBe('llama3.2')
expect(payload.temperature).toBe(0.5)
expect(payload.max_tokens).toBe(128)
})
it('returns content verbatim (keeps ```json fences) when no responseFormat', async () => {
const fenced = '```json\n{"a":1}\n```'
mockCreate.mockResolvedValue(completion({ content: fenced }))
const result = (await ollamaProvider.executeRequest(baseRequest)) as ProviderResponse
expect(result.content).toBe(fenced)
})
it('strips ```json fences and requests JSON mode with schema instructions when responseFormat is set', async () => {
mockCreate.mockResolvedValue(completion({ content: '```json\n{"a":1}\n```' }))
const result = (await ollamaProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'r', schema: { type: 'object' }, strict: true },
})) as ProviderResponse
expect(result.content).toBe('{"a":1}')
const payload = mockCreate.mock.calls[0][0]
expect(payload.response_format).toEqual({ type: 'json_object' })
expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'SCHEMA_INSTRUCTIONS' })
})
it('defers structured output while tools run, then makes a final JSON-mode call', async () => {
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'intermediate' }))
.mockResolvedValueOnce(completion({ content: '{"a":1}' }))
const result = (await ollamaProvider.executeRequest({
...baseRequest,
tools: [makeTool('mytool')],
responseFormat: { name: 'r', schema: { type: 'object' } },
})) as ProviderResponse
expect(mockCreate).toHaveBeenCalledTimes(3)
expect(mockCreate.mock.calls[0][0].response_format).toBeUndefined()
expect(mockCreate.mock.calls[0][0].tools).toBeDefined()
const finalCall = mockCreate.mock.calls[2][0]
expect(finalCall.response_format).toEqual({ type: 'json_object' })
expect(finalCall.tools).toBeUndefined()
expect(finalCall.messages.at(-1)).toEqual({ role: 'user', content: 'SCHEMA_INSTRUCTIONS' })
expect(result.content).toBe('{"a":1}')
})
it('runs the tool loop: parses string args, feeds results back, then terminates', async () => {
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'done' }))
const result = (await ollamaProvider.executeRequest({
...baseRequest,
tools: [makeTool('mytool')],
})) as ProviderResponse
expect(mockExecuteTool).toHaveBeenCalledWith('mytool', { x: 1 }, expect.anything())
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(result.content).toBe('done')
expect(result.toolCalls).toEqual([
expect.objectContaining({ name: 'mytool', success: true, arguments: { x: 1 } }),
])
expect(result.toolResults).toEqual([{ ok: true }])
const followUp = mockCreate.mock.calls[1][0].messages
expect(followUp).toContainEqual(
expect.objectContaining({
role: 'assistant',
content: null,
tool_calls: [
expect.objectContaining({
id: 'call_1',
function: { name: 'mytool', arguments: '{"x":1}' },
}),
],
})
)
expect(followUp).toContainEqual({
role: 'tool',
tool_call_id: 'call_1',
content: JSON.stringify({ ok: true }),
})
})
it('records a failed tool result without aborting the loop', async () => {
mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' })
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'recovered' }))
const result = (await ollamaProvider.executeRequest({
...baseRequest,
tools: [makeTool('mytool')],
})) as ProviderResponse
expect(result.content).toBe('recovered')
expect(result.toolCalls?.[0]).toMatchObject({ name: 'mytool', success: false })
const toolMsg = mockCreate.mock.calls[1][0].messages.find(
(m: { role: string }) => m.role === 'tool'
)
expect(JSON.parse(toolMsg.content)).toMatchObject({ error: true, message: 'boom' })
})
it('executes parallel tool calls from a single response', async () => {
mockExecuteTool
.mockResolvedValueOnce({ success: true, output: { from: 'a' } })
.mockResolvedValueOnce({ success: true, output: { from: 'b' } })
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_a', type: 'function', function: { name: 'a', arguments: '{}' } },
{ id: 'call_b', type: 'function', function: { name: 'b', arguments: '{}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'summary' }))
const result = (await ollamaProvider.executeRequest({
...baseRequest,
tools: [makeTool('a'), makeTool('b')],
})) as ProviderResponse
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
expect(result.toolCalls?.map((c) => c.name)).toEqual(['a', 'b'])
const toolMsgs = mockCreate.mock.calls[1][0].messages.filter(
(m: { role: string }) => m.role === 'tool'
)
expect(toolMsgs.map((m: { tool_call_id: string }) => m.tool_call_id)).toEqual([
'call_a',
'call_b',
])
})
it('filters out tools with usageControl "none"', async () => {
await ollamaProvider.executeRequest({
...baseRequest,
tools: [makeTool('keep'), makeTool('drop', 'none')],
})
const sent = mockCreate.mock.calls[0][0].tools
expect(sent.map((t: { function: { name: string } }) => t.function.name)).toEqual(['keep'])
})
it('never forces tools (Ollama ignores tool_choice) and keeps "auto"', async () => {
await ollamaProvider.executeRequest({ ...baseRequest, tools: [makeTool('forced', 'force')] })
const payload = mockCreate.mock.calls[0][0]
expect(payload.tool_choice).toBe('auto')
expect(payload.tools.map((t: { function: { name: string } }) => t.function.name)).toEqual([
'forced',
])
})
it('surfaces an OpenAI APIError message through ProviderError', async () => {
mockCreate.mockRejectedValue(
new MockAPIError('model not found', {
status: 404,
code: 'not_found',
type: 'invalid_request_error',
})
)
await expect(ollamaProvider.executeRequest(baseRequest)).rejects.toThrow('model not found')
})
it('streams content and usage when no tools are used', async () => {
const result = (await ollamaProvider.executeRequest({
...baseRequest,
stream: true,
})) as unknown as StreamingResult
expect(result.stream).toBe('OLLAMA_STREAM')
expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true })
streamOnComplete.current?.('streamed text', {
prompt_tokens: 4,
completion_tokens: 6,
total_tokens: 10,
})
expect(result.execution.output.content).toBe('streamed text')
expect(result.execution.output.tokens).toMatchObject({ input: 4, output: 6, total: 10 })
})
it('strips ```json fences from streamed content when responseFormat is set', async () => {
const result = (await ollamaProvider.executeRequest({
...baseRequest,
stream: true,
responseFormat: { name: 'r', schema: { type: 'object' }, strict: true },
})) as unknown as StreamingResult
streamOnComplete.current?.('```json\n{"a":1}\n```', {
prompt_tokens: 1,
completion_tokens: 2,
total_tokens: 3,
})
expect(result.execution.output.content).toBe('{"a":1}')
})
it('streams the final response after a tool loop, carrying tool calls', async () => {
mockCreate
.mockResolvedValueOnce(
completion({
toolCalls: [
{ id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } },
],
})
)
.mockResolvedValueOnce(completion({ content: 'intermediate' }))
const result = (await ollamaProvider.executeRequest({
...baseRequest,
stream: true,
tools: [makeTool('mytool')],
})) as unknown as StreamingResult
expect(result.stream).toBe('OLLAMA_STREAM')
expect(mockExecuteTool).toHaveBeenCalledTimes(1)
const finalCall = mockCreate.mock.calls[2][0]
expect(finalCall.tools).toBeUndefined()
expect(finalCall.tool_choice).toBeUndefined()
streamOnComplete.current?.('final answer', {
prompt_tokens: 2,
completion_tokens: 4,
total_tokens: 6,
})
expect(result.execution.output.content).toBe('final answer')
expect(result.execution.output.toolCalls).toMatchObject({ count: 1 })
})
})
+62
View File
@@ -0,0 +1,62 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import OpenAI from 'openai'
import { getOllamaUrl } from '@/lib/core/utils/urls'
import type { StreamingExecution } from '@/executor/types'
import { executeOllamaProviderRequest } from '@/providers/ollama/core'
import type { ModelsObject } from '@/providers/ollama/types'
import { createReadableStreamFromOllamaStream } from '@/providers/ollama/utils'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
import { useProvidersStore } from '@/stores/providers'
const logger = createLogger('OllamaProvider')
const OLLAMA_HOST = getOllamaUrl()
export const ollamaProvider: ProviderConfig = {
id: 'ollama',
name: 'Ollama',
description: 'Local Ollama server for LLM inference',
version: '1.0.0',
models: [],
defaultModel: '',
async initialize() {
if (typeof window !== 'undefined') {
logger.info('Skipping Ollama initialization on client side to avoid CORS issues')
return
}
try {
const response = await fetch(`${OLLAMA_HOST}/api/tags`)
if (!response.ok) {
await response.text().catch(() => {})
useProvidersStore.getState().setProviderModels('ollama', [])
logger.warn('Ollama service is not available. The provider will be disabled.')
return
}
const data = (await response.json()) as ModelsObject
this.models = data.models.map((model) => model.name)
useProvidersStore.getState().setProviderModels('ollama', this.models)
} catch (error) {
logger.warn('Ollama model instantiation failed. The provider will be disabled.', {
error: getErrorMessage(error, 'Unknown error'),
})
}
},
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
return executeOllamaProviderRequest(request, {
providerId: 'ollama',
providerLabel: 'Ollama',
createClient: () =>
new OpenAI({
apiKey: 'empty',
baseURL: `${OLLAMA_HOST}/v1`,
}),
createStream: createReadableStreamFromOllamaStream,
logger,
})
},
}
+12
View File
@@ -0,0 +1,12 @@
interface Model {
name: string
model: string
modified_at: string
size: number
digest: string
details: object
}
export interface ModelsObject {
models: Model[]
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from an Ollama streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromOllamaStream(
ollamaStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(ollamaStream, 'Ollama', onComplete)
}
+827
View File
@@ -0,0 +1,827 @@
import type { Logger } from '@sim/logger'
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'
import {
calculateCost,
enforceStrictSchema,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
import {
buildResponsesInputFromMessages,
convertResponseOutputToInputItems,
convertToolsToResponses,
createReadableStreamFromResponses,
extractResponseReasoning,
extractResponseText,
extractResponseToolCalls,
parseResponsesUsage,
type ResponsesInputItem,
type ResponsesToolCall,
toResponsesToolChoice,
} from './utils'
type PreparedTools = ReturnType<typeof prepareToolsWithUsageControl>
type ToolChoice = PreparedTools['toolChoice']
export interface ResponsesProviderConfig {
providerId: string
providerLabel: string
modelName: string
endpoint: string
headers: Record<string, string>
logger: Logger
/**
* Optional fetch implementation. Used to pin the connection to a pre-validated
* IP (DNS-rebinding/SSRF protection) when the endpoint is user-supplied.
* Defaults to the global fetch.
*/
fetch?: typeof fetch
}
/**
* Executes a Responses API request with tool-loop handling and streaming support.
*/
export async function executeResponsesProviderRequest(
request: ProviderRequest,
config: ResponsesProviderConfig
): Promise<ProviderResponse | StreamingExecution> {
const { logger } = config
const fetchImpl = config.fetch ?? fetch
logger.info(`Preparing ${config.providerLabel} request`, {
model: request.model,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const initialInput = buildResponsesInputFromMessages(allMessages, config.providerId)
const basePayload: Record<string, unknown> = {
model: config.modelName,
}
if (request.temperature !== undefined) basePayload.temperature = request.temperature
if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') {
basePayload.reasoning = {
effort: request.reasoningEffort,
summary: 'auto',
}
}
if (request.verbosity !== undefined && request.verbosity !== 'auto') {
basePayload.text = {
...((basePayload.text as Record<string, unknown>) ?? {}),
verbosity: request.verbosity,
}
}
// Store response format config - for Azure with tools, we defer applying it until after tool calls complete
let deferredTextFormat: OpenAI.Responses.ResponseFormatTextJSONSchemaConfig | undefined
const hasTools = !!request.tools?.length
const isAzure = config.providerId === 'azure-openai'
if (request.responseFormat) {
const isStrict = request.responseFormat.strict !== false
const rawSchema = request.responseFormat.schema || request.responseFormat
// OpenAI strict mode requires additionalProperties: false on ALL nested objects
const cleanedSchema = isStrict ? enforceStrictSchema(rawSchema) : rawSchema
const textFormat = {
type: 'json_schema' as const,
name: request.responseFormat.name || 'response_schema',
schema: cleanedSchema,
strict: isStrict,
}
// Azure OpenAI has issues combining tools + response_format in the same request
// Defer the format until after tool calls complete for Azure
if (isAzure && hasTools) {
deferredTextFormat = textFormat
logger.info(
`Deferring JSON schema response format for ${config.providerLabel} (will apply after tool calls complete)`
)
} else {
basePayload.text = {
...((basePayload.text as Record<string, unknown>) ?? {}),
format: textFormat,
}
logger.info(`Added JSON schema response format to ${config.providerLabel} request`)
}
}
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
let preparedTools: PreparedTools | null = null
let responsesToolChoice: ReturnType<typeof toResponsesToolChoice> | undefined
let trackingToolChoice: ToolChoice | undefined
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, config.providerId)
const { tools: filteredTools, toolChoice } = preparedTools
trackingToolChoice = toolChoice
if (filteredTools?.length) {
const convertedTools = convertToolsToResponses(filteredTools)
if (!convertedTools.length) {
throw new Error('All tools have empty names')
}
basePayload.tools = convertedTools
basePayload.parallel_tool_calls = true
}
if (toolChoice) {
responsesToolChoice = toResponsesToolChoice(toolChoice)
if (responsesToolChoice) {
basePayload.tool_choice = responsesToolChoice
}
logger.info(`${config.providerLabel} request configuration:`, {
toolCount: filteredTools?.length || 0,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function?.name}`
: toolChoice.type === 'tool'
? `force:${toolChoice.name}`
: toolChoice.type === 'any'
? `force:${toolChoice.any?.name || 'unknown'}`
: 'unknown',
model: config.modelName,
})
}
}
const createRequestBody = (
input: ResponsesInputItem[],
overrides: Record<string, unknown> = {}
) => ({
...basePayload,
input,
...overrides,
})
const parseErrorResponse = async (response: Response): Promise<string> => {
const text = await response.text()
try {
const payload = JSON.parse(text)
return payload?.error?.message || text
} catch {
return text
}
}
const postResponses = async (
body: Record<string, unknown>
): Promise<OpenAI.Responses.Response> => {
const response = await fetchImpl(config.endpoint, {
method: 'POST',
headers: config.headers,
body: JSON.stringify(body),
signal: request.abortSignal,
})
if (!response.ok) {
const message = await parseErrorResponse(response)
throw new Error(`${config.providerLabel} API error (${response.status}): ${message}`)
}
return response.json()
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.stream && (!tools || tools.length === 0)) {
logger.info(`Using streaming response for ${config.providerLabel} request`)
const streamResponse = await fetchImpl(config.endpoint, {
method: 'POST',
headers: config.headers,
body: JSON.stringify(createRequestBody(initialInput, { stream: true })),
signal: request.abortSignal,
})
if (!streamResponse.ok) {
const message = await parseErrorResponse(streamResponse)
throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`)
}
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,
}
const costResult = calculateCost(
request.model,
usage?.promptTokens || 0,
usage?.completionTokens || 0
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let hasUsedForcedTool = false
let currentToolChoice = responsesToolChoice
let currentTrackingToolChoice = trackingToolChoice
const checkForForcedToolUsage = (
toolCallsInResponse: ResponsesToolCall[],
toolChoice: ToolChoice | undefined
) => {
if (typeof toolChoice === 'object' && toolCallsInResponse.length > 0) {
const result = trackForcedToolUsage(
toolCallsInResponse,
toolChoice,
logger,
config.providerId,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
}
const currentInput: ResponsesInputItem[] = [...initialInput]
let currentResponse = await postResponses(
createRequestBody(currentInput, { tool_choice: currentToolChoice })
)
const firstResponseTime = Date.now() - initialCallTime
const initialUsage = parseResponsesUsage(currentResponse.usage)
const tokens = {
input: initialUsage?.promptTokens || 0,
output: initialUsage?.completionTokens || 0,
total: initialUsage?.totalTokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let content = extractResponseText(currentResponse.output) || ''
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
checkForForcedToolUsage(
extractResponseToolCalls(currentResponse.output),
currentTrackingToolChoice
)
while (iterationCount < MAX_TOOL_ITERATIONS) {
const responseText = extractResponseText(currentResponse.output)
if (responseText) {
content = responseText
}
const toolCallsInResponse = extractResponseToolCalls(currentResponse.output)
enrichLastModelSegmentFromOpenAIResponse(
timeSegments,
currentResponse,
responseText,
toolCallsInResponse,
{ model: request.model }
)
if (!toolCallsInResponse.length) {
break
}
const outputInputItems = convertResponseOutputToInputItems(currentResponse.output)
if (outputInputItems.length) {
currentInput.push(...outputInputItems)
}
logger.info(
`Processing ${toolCallsInResponse.length} tool calls in parallel (iteration ${
iterationCount + 1
}/${MAX_TOOL_ITERATIONS})`
)
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.name
try {
const toolArgs = toolCall.arguments ? JSON.parse(toolCall.arguments) : {}
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) {
return null
}
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: Record<string, unknown>
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output as Record<string, unknown>
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentInput.push({
type: 'function_call_output',
call_id: toolCall.id,
output: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
if (typeof currentToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
currentToolChoice = {
type: 'function',
name: remainingTools[0],
}
currentTrackingToolChoice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
currentToolChoice = 'auto'
currentTrackingToolChoice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await postResponses(
createRequestBody(currentInput, { tool_choice: currentToolChoice })
)
checkForForcedToolUsage(
extractResponseToolCalls(currentResponse.output),
currentTrackingToolChoice
)
const latestText = extractResponseText(currentResponse.output)
if (latestText) {
content = latestText
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
const usage = parseResponsesUsage(currentResponse.usage)
if (usage) {
tokens.input += usage.promptTokens
tokens.output += usage.completionTokens
tokens.total += usage.totalTokens
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
const trailingText = extractResponseText(currentResponse.output)
const trailingToolCalls = extractResponseToolCalls(currentResponse.output)
enrichLastModelSegmentFromOpenAIResponse(
timeSegments,
currentResponse,
trailingText,
trailingToolCalls,
{ model: request.model }
)
}
// For Azure with deferred format: make a final call with the response format applied
// This happens whenever we have a deferred format, even if no tools were called
// (the initial call was made without the format, so we need to apply it now)
let appliedDeferredFormat = false
if (deferredTextFormat) {
logger.info(
`Applying deferred JSON schema response format for ${config.providerLabel} (iterationCount: ${iterationCount})`
)
const finalFormatStartTime = Date.now()
// Determine what input to use for the formatted call
let formattedInput: ResponsesInputItem[]
if (iterationCount > 0) {
// Tools were called - include the conversation history with tool results
const lastOutputItems = convertResponseOutputToInputItems(currentResponse.output)
if (lastOutputItems.length) {
currentInput.push(...lastOutputItems)
}
formattedInput = currentInput
} else {
// No tools were called - just retry the initial call with format applied
// Don't include the model's previous unformatted response
formattedInput = initialInput
}
// Make final call with the response format - build payload without tools
const finalPayload: Record<string, unknown> = {
model: config.modelName,
input: formattedInput,
text: {
...((basePayload.text as Record<string, unknown>) ?? {}),
format: deferredTextFormat,
},
}
// Copy over non-tool related settings
if (request.temperature !== undefined) finalPayload.temperature = request.temperature
if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') {
finalPayload.reasoning = {
effort: request.reasoningEffort,
summary: 'auto',
}
}
if (request.verbosity !== undefined && request.verbosity !== 'auto') {
finalPayload.text = {
...((finalPayload.text as Record<string, unknown>) ?? {}),
verbosity: request.verbosity,
}
}
currentResponse = await postResponses(finalPayload)
const finalFormatEndTime = Date.now()
const finalFormatDuration = finalFormatEndTime - finalFormatStartTime
timeSegments.push({
type: 'model',
name: 'Final formatted response',
startTime: finalFormatStartTime,
endTime: finalFormatEndTime,
duration: finalFormatDuration,
})
modelTime += finalFormatDuration
const finalUsage = parseResponsesUsage(currentResponse.usage)
if (finalUsage) {
tokens.input += finalUsage.promptTokens
tokens.output += finalUsage.completionTokens
tokens.total += finalUsage.totalTokens
}
// Update content with the formatted response
const formattedText = extractResponseText(currentResponse.output)
if (formattedText) {
content = formattedText
}
enrichLastModelSegmentFromOpenAIResponse(
timeSegments,
currentResponse,
formattedText,
extractResponseToolCalls(currentResponse.output),
{ model: request.model }
)
appliedDeferredFormat = true
}
// Skip streaming if we already applied deferred format - we have the formatted content
// Making another streaming call would lose the formatted response
if (request.stream && !appliedDeferredFormat) {
logger.info('Using streaming for final response after tool processing')
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
// For Azure with deferred format in streaming mode, include the format in the streaming call
const streamOverrides: Record<string, unknown> = { stream: true, tool_choice: 'auto' }
if (deferredTextFormat) {
streamOverrides.text = {
...((basePayload.text as Record<string, unknown>) ?? {}),
format: deferredTextFormat,
}
}
const streamResponse = await fetchImpl(config.endpoint, {
method: 'POST',
headers: config.headers,
body: JSON.stringify(createRequestBody(currentInput, streamOverrides)),
signal: request.abortSignal,
})
if (!streamResponse.ok) {
const message = await parseErrorResponse(streamResponse)
throw new Error(`${config.providerLabel} API error (${streamResponse.status}): ${message}`)
}
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 }) =>
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
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error(`Error in ${config.providerLabel} request:`, {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
}
/**
* Determines a finish reason for an OpenAI Responses API response.
* Maps to conventional values: 'tool_calls' | 'length' | 'stop'.
*/
function deriveOpenAIFinishReason(
response: OpenAI.Responses.Response,
toolCalls: ResponsesToolCall[]
): string | undefined {
const incompleteReason = response.incomplete_details?.reason
if (incompleteReason === 'max_output_tokens') return 'length'
if (incompleteReason === 'content_filter') return 'content_filter'
if (toolCalls.length > 0) return 'tool_calls'
if (incompleteReason) return incompleteReason
if (response.status === 'failed') return 'error'
if (response.status === 'incomplete') return 'length'
if (response.status && response.status !== 'completed') return response.status
return 'stop'
}
/**
* Enriches the last model segment with per-iteration content extracted from an
* OpenAI Responses API response: assistant text, tool calls, finish reason,
* and token usage for the iteration.
*/
function enrichLastModelSegmentFromOpenAIResponse(
timeSegments: TimeSegment[],
response: OpenAI.Responses.Response,
assistantText: string,
toolCallsInResponse: ResponsesToolCall[],
extras?: {
model?: string
ttft?: number
errorType?: string
errorMessage?: string
}
): void {
const toolCalls: IterationToolCall[] = toolCallsInResponse.map((tc) => ({
id: tc.id,
name: tc.name,
arguments:
typeof tc.arguments === 'string' ? parseToolCallArguments(tc.arguments) : tc.arguments,
}))
const usage = parseResponsesUsage(response.usage)
const thinkingContent = extractResponseReasoning(response.output)
let cost: { input: number; output: number; total: number } | undefined
if (extras?.model && usage) {
const full = calculateCost(
extras.model,
usage.promptTokens,
usage.completionTokens,
usage.cachedTokens > 0
)
cost = { input: full.input, output: full.output, total: full.total }
}
enrichLastModelSegment(timeSegments, {
assistantContent: assistantText || undefined,
thinkingContent: thinkingContent || undefined,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
finishReason: deriveOpenAIFinishReason(response, toolCallsInResponse),
tokens: usage
? {
input: usage.promptTokens,
output: usage.completionTokens,
total: usage.totalTokens,
...(usage.cachedTokens > 0 && { cacheRead: usage.cachedTokens }),
...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }),
}
: undefined,
cost,
provider: 'openai',
ttft: extras?.ttft,
errorType: extras?.errorType,
errorMessage: extras?.errorMessage,
})
}
+38
View File
@@ -0,0 +1,38 @@
import { createLogger } from '@sim/logger'
import type { StreamingExecution } from '@/executor/types'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
import { executeResponsesProviderRequest } from './core'
const logger = createLogger('OpenAIProvider')
const responsesEndpoint = 'https://api.openai.com/v1/responses'
export const openaiProvider: ProviderConfig = {
id: 'openai',
name: 'OpenAI',
description: "OpenAI's GPT models",
version: '1.0.0',
models: getProviderModels('openai'),
defaultModel: getProviderDefaultModel('openai'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for OpenAI')
}
return executeResponsesProviderRequest(request, {
providerId: 'openai',
providerLabel: 'OpenAI',
modelName: request.model,
endpoint: responsesEndpoint,
headers: {
Authorization: `Bearer ${request.apiKey}`,
'Content-Type': 'application/json',
'OpenAI-Beta': 'responses=v1',
},
logger,
})
},
}
+41
View File
@@ -0,0 +1,41 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildResponsesInputFromMessages } from '@/providers/openai/utils'
describe('buildResponsesInputFromMessages', () => {
it('should convert user message files to Responses multipart content', () => {
const input = buildResponsesInputFromMessages([
{
role: 'user',
content: 'Analyze this image',
files: [
{
id: 'file-1',
key: 'workspace/ws-1/example.png',
name: 'example.png',
url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace',
size: 128,
type: 'image/png',
base64: 'iVBORw0KGgo=',
},
],
},
])
expect(input).toEqual([
{
role: 'user',
content: [
{ type: 'input_text', text: 'Analyze this image' },
{
type: 'input_image',
image_url: 'data:image/png;base64,iVBORw0KGgo=',
detail: 'auto',
},
],
},
])
})
})
+537
View File
@@ -0,0 +1,537 @@
import { createLogger } from '@sim/logger'
import type OpenAI from 'openai'
import { buildOpenAIMessageContent } from '@/providers/attachments'
import type { Message } from '@/providers/types'
const logger = createLogger('ResponsesUtils')
export interface ResponsesUsageTokens {
promptTokens: number
completionTokens: number
totalTokens: number
cachedTokens: number
reasoningTokens: number
}
export interface ResponsesToolCall {
id: string
name: string
arguments: string
}
export type ResponsesInputItem =
| {
role: 'system' | 'user' | 'assistant'
content: string | OpenAI.Responses.ResponseInputContent[]
}
| {
type: 'function_call'
call_id: string
name: string
arguments: string
}
| {
type: 'function_call_output'
call_id: string
output: string
}
export interface ResponsesToolDefinition {
type: 'function'
name: string
description?: string
parameters?: Record<string, unknown>
}
/**
* Converts chat-style messages into Responses API input items.
*/
export function buildResponsesInputFromMessages(
messages: Message[],
providerId = 'openai'
): ResponsesInputItem[] {
const input: ResponsesInputItem[] = []
for (const message of messages) {
if (message.role === 'tool' && message.tool_call_id) {
input.push({
type: 'function_call_output',
call_id: message.tool_call_id,
output: message.content ?? '',
})
continue
}
if (message.role === 'system' || message.role === 'user' || message.role === 'assistant') {
const content =
message.role === 'user'
? buildOpenAIMessageContent(message.content, message.files, providerId)
: (message.content ?? '')
if (
(typeof content === 'string' && !content) ||
(Array.isArray(content) && content.length === 0)
) {
continue
}
input.push({
role: message.role,
content,
})
}
if (message.tool_calls?.length) {
for (const toolCall of message.tool_calls) {
input.push({
type: 'function_call',
call_id: toolCall.id,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
})
}
}
}
return input
}
/**
* Converts tool definitions to the Responses API format.
*/
export function convertToolsToResponses(
tools: Array<{
type?: string
name?: string
description?: string
parameters?: Record<string, unknown>
function?: { name: string; description?: string; parameters?: Record<string, unknown> }
}>
): ResponsesToolDefinition[] {
return tools
.map((tool) => {
const name = tool.function?.name ?? tool.name
if (!name) {
return null
}
return {
type: 'function' as const,
name,
description: tool.function?.description ?? tool.description,
parameters: tool.function?.parameters ?? tool.parameters,
}
})
.filter(Boolean) as ResponsesToolDefinition[]
}
/**
* Converts tool_choice to the Responses API format.
*/
export function toResponsesToolChoice(
toolChoice:
| 'auto'
| 'none'
| { type: 'function'; function?: { name: string }; name?: string }
| { type: 'tool'; name: string }
| { type: 'any'; any: { model: string; name: string } }
| undefined
): 'auto' | 'none' | { type: 'function'; name: string } | undefined {
if (!toolChoice) {
return undefined
}
if (typeof toolChoice === 'string') {
return toolChoice
}
if (toolChoice.type === 'function') {
const name = toolChoice.name ?? toolChoice.function?.name
return name ? { type: 'function', name } : undefined
}
return 'auto'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function extractTextFromMessageItem(item: unknown): string {
if (!isRecord(item)) {
return ''
}
if (typeof item.content === 'string') {
return item.content
}
if (!Array.isArray(item.content)) {
return ''
}
const textParts: string[] = []
for (const part of item.content) {
if (!isRecord(part)) {
continue
}
if ((part.type === 'output_text' || part.type === 'text') && typeof part.text === 'string') {
textParts.push(part.text)
continue
}
if (part.type === 'output_json') {
if (typeof part.text === 'string') {
textParts.push(part.text)
} else if (part.json !== undefined) {
textParts.push(JSON.stringify(part.json))
}
}
}
return textParts.join('')
}
/**
* Extracts plain text from Responses API output items.
*/
export function extractResponseText(output: OpenAI.Responses.ResponseOutputItem[]): string {
if (!Array.isArray(output)) {
return ''
}
const textParts: string[] = []
for (const item of output) {
if (item?.type !== 'message') {
continue
}
const text = extractTextFromMessageItem(item)
if (text) {
textParts.push(text)
}
}
return textParts.join('')
}
/**
* Extracts reasoning summary text from Responses API output items. Reasoning
* items (emitted by o1/o3/gpt-5) carry a `summary[]` of `{ type, text }` entries
* — we join the text for trace display. The raw `encrypted_content` is left
* alone; it's opaque plumbing for round-tripping across turns.
*/
export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutputItem[]): string {
if (!Array.isArray(output)) return ''
const parts: string[] = []
for (const item of output) {
if (!item || item.type !== 'reasoning') continue
const summary = (item as unknown as { summary?: Array<{ text?: string | null } | null> })
.summary
if (!Array.isArray(summary)) continue
for (const entry of summary) {
const text = entry?.text
if (typeof text === 'string' && text.length > 0) parts.push(text)
}
}
return parts.join('\n\n')
}
/**
* Converts Responses API output items into input items for subsequent calls.
*/
export function convertResponseOutputToInputItems(
output: OpenAI.Responses.ResponseOutputItem[]
): ResponsesInputItem[] {
if (!Array.isArray(output)) {
return []
}
const items: ResponsesInputItem[] = []
for (const item of output) {
if (!isRecord(item)) {
continue
}
if (item.type === 'message') {
const text = extractTextFromMessageItem(item)
if (text) {
items.push({
role: 'assistant',
content: text,
})
}
// Handle Chat Completions-style tool_calls nested under message items
const toolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []
for (const toolCall of toolCalls) {
const tc = toolCall as Record<string, unknown>
const fn = tc.function as Record<string, unknown> | undefined
const callId = tc.id as string | undefined
const name = (fn?.name ?? tc.name) as string | undefined
if (!callId || !name) {
continue
}
const argumentsValue =
typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {})
items.push({
type: 'function_call',
call_id: callId,
name,
arguments: argumentsValue,
})
}
continue
}
if (item.type === 'function_call') {
const fc = item as OpenAI.Responses.ResponseFunctionToolCall
const callId = fc.call_id ?? (typeof item.id === 'string' ? item.id : undefined)
const name =
fc.name ??
(isRecord(item.function) && typeof item.function.name === 'string'
? item.function.name
: undefined)
if (!callId || !name) {
continue
}
const argumentsValue =
typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {})
items.push({
type: 'function_call',
call_id: callId,
name,
arguments: argumentsValue,
})
}
}
return items
}
/**
* Extracts tool calls from Responses API output items.
*/
export function extractResponseToolCalls(
output: OpenAI.Responses.ResponseOutputItem[]
): ResponsesToolCall[] {
if (!Array.isArray(output)) {
return []
}
const toolCalls: ResponsesToolCall[] = []
for (const item of output) {
if (!isRecord(item)) {
continue
}
if (item.type === 'function_call') {
const fc = item as OpenAI.Responses.ResponseFunctionToolCall
const callId = fc.call_id ?? (typeof item.id === 'string' ? item.id : undefined)
const name =
fc.name ??
(isRecord(item.function) && typeof item.function.name === 'string'
? item.function.name
: undefined)
if (!callId || !name) {
continue
}
const argumentsValue =
typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {})
toolCalls.push({
id: callId,
name,
arguments: argumentsValue,
})
continue
}
// Handle Chat Completions-style tool_calls nested under message items
if (item.type === 'message' && Array.isArray(item.tool_calls)) {
for (const toolCall of item.tool_calls) {
const tc = toolCall as Record<string, unknown>
const fn = tc.function as Record<string, unknown> | undefined
const callId = tc.id as string | undefined
const name = (fn?.name ?? tc.name) as string | undefined
if (!callId || !name) {
continue
}
const argumentsValue =
typeof fn?.arguments === 'string' ? fn.arguments : JSON.stringify(fn?.arguments ?? {})
toolCalls.push({
id: callId,
name,
arguments: argumentsValue,
})
}
}
}
return toolCalls
}
/**
* Maps Responses API usage data to prompt/completion token counts.
*
* Note: output_tokens is expected to include reasoning tokens; fall back to reasoning_tokens
* when output_tokens is missing or zero.
*/
export function parseResponsesUsage(
usage: OpenAI.Responses.ResponseUsage | undefined
): ResponsesUsageTokens | undefined {
if (!usage) {
return undefined
}
const inputTokens = usage.input_tokens ?? 0
const outputTokens = usage.output_tokens ?? 0
const cachedTokens = usage.input_tokens_details?.cached_tokens ?? 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0
const completionTokens = Math.max(outputTokens, reasoningTokens)
const totalTokens = inputTokens + completionTokens
return {
promptTokens: inputTokens,
completionTokens,
totalTokens,
cachedTokens,
reasoningTokens,
}
}
/**
* Creates a ReadableStream from a Responses API SSE stream.
*/
export function createReadableStreamFromResponses(
response: Response,
onComplete?: (content: string, usage?: ResponsesUsageTokens) => void
): ReadableStream<Uint8Array> {
let fullContent = ''
let finalUsage: ResponsesUsageTokens | undefined
let activeEventType: string | undefined
const encoder = new TextEncoder()
return new ReadableStream<Uint8Array>({
async start(controller) {
const reader = response.body?.getReader()
if (!reader) {
controller.close()
return
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
if (trimmed.startsWith('event:')) {
activeEventType = trimmed.slice(6).trim()
continue
}
if (!trimmed.startsWith('data:')) {
continue
}
const data = trimmed.slice(5).trim()
if (data === '[DONE]') {
continue
}
let event: Record<string, unknown>
try {
event = JSON.parse(data)
} catch (error) {
logger.debug('Skipping non-JSON response stream chunk', {
data: data.slice(0, 200),
error,
})
continue
}
const eventType = event?.type ?? activeEventType
if (
eventType === 'response.error' ||
eventType === 'error' ||
eventType === 'response.failed'
) {
const errorObj = event.error as Record<string, unknown> | undefined
const message = (errorObj?.message as string) || 'Responses API stream error'
controller.error(new Error(message))
return
}
if (
eventType === 'response.output_text.delta' ||
eventType === 'response.output_json.delta'
) {
let deltaText = ''
const delta = event.delta as string | Record<string, unknown> | undefined
if (typeof delta === 'string') {
deltaText = delta
} else if (delta && typeof delta.text === 'string') {
deltaText = delta.text
} else if (delta && delta.json !== undefined) {
deltaText = JSON.stringify(delta.json)
} else if (event.json !== undefined) {
deltaText = JSON.stringify(event.json)
} else if (typeof event.text === 'string') {
deltaText = event.text
}
if (deltaText.length > 0) {
fullContent += deltaText
controller.enqueue(encoder.encode(deltaText))
}
}
if (eventType === 'response.completed') {
const responseObj = event.response as Record<string, unknown> | undefined
const usageData = (responseObj?.usage ?? event.usage) as
| OpenAI.Responses.ResponseUsage
| undefined
finalUsage = parseResponsesUsage(usageData)
}
}
}
if (onComplete) {
onComplete(fullContent, finalUsage)
}
controller.close()
} catch (error) {
controller.error(error)
} finally {
reader.releaseLock()
}
},
})
}
+351
View File
@@ -0,0 +1,351 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCreate,
mockExecuteTool,
mockSupportsNative,
mockPrepareTools,
mockCheckForced,
mockCreateStream,
} = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockExecuteTool: vi.fn(),
mockSupportsNative: vi.fn(),
mockPrepareTools: vi.fn((tools: unknown) => ({
tools,
toolChoice: 'auto',
forcedTools: [],
hasFilteredTools: false,
})),
mockCheckForced: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })),
mockCreateStream: vi.fn(),
}))
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(
class {
chat = { completions: { create: mockCreate } }
}
),
}))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 10 }))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn().mockReturnValue([]),
getProviderDefaultModel: vi.fn().mockReturnValue(''),
}))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: vi.fn((messages: unknown) => messages),
}))
vi.mock('@/providers/openrouter/utils', () => ({
supportsNativeStructuredOutputs: mockSupportsNative,
createReadableStreamFromOpenAIStream: mockCreateStream,
checkForForcedToolUsage: mockCheckForced,
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })),
prepareToolsWithUsageControl: mockPrepareTools,
prepareToolExecution: vi.fn((_tool: unknown, toolArgs: Record<string, unknown>) => ({
toolParams: toolArgs,
executionParams: toolArgs,
})),
sumToolCosts: vi.fn(() => 0),
generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'),
}))
import { openRouterProvider } from '@/providers/openrouter/index'
import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types'
interface Usage {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
function textResponse(
content: string,
usage: Usage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
) {
return {
choices: [{ message: { content, tool_calls: undefined }, finish_reason: 'stop' }],
usage,
}
}
function toolCallResponse(name: string, args: Record<string, unknown>, id = 'call_1') {
return {
choices: [
{
message: {
content: null,
tool_calls: [
{ id, type: 'function', function: { name, arguments: JSON.stringify(args) } },
],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 },
}
}
function tool(id: string): ProviderToolConfig {
return {
id,
name: id,
description: 'test tool',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
}
}
const baseRequest: ProviderRequest = {
apiKey: 'sk-or-test',
model: 'openrouter/anthropic/claude-3.5-sonnet',
systemPrompt: 'You are helpful.',
messages: [{ role: 'user', content: 'Hello' }],
}
describe('openRouterProvider.executeRequest', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockReset()
mockExecuteTool.mockReset()
mockSupportsNative.mockResolvedValue(false)
})
it('requires an API key', async () => {
await expect(
openRouterProvider.executeRequest({ model: 'openrouter/x', messages: [] })
).rejects.toThrow('API key is required for OpenRouter')
})
it('strips the openrouter/ prefix and returns content + tokens', async () => {
mockCreate.mockResolvedValueOnce(textResponse('Hi there'))
const res = (await openRouterProvider.executeRequest(baseRequest)) as ProviderResponse
expect(res.content).toBe('Hi there')
expect(res.model).toBe('anthropic/claude-3.5-sonnet')
expect(res.tokens).toEqual({ input: 10, output: 5, total: 15 })
const payload = mockCreate.mock.calls[0][0]
expect(payload.model).toBe('anthropic/claude-3.5-sonnet')
expect(payload.messages[0]).toEqual({ role: 'system', content: 'You are helpful.' })
expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'Hello' })
})
it('inserts context as a user message between system and history', async () => {
mockCreate.mockResolvedValueOnce(textResponse('ok'))
await openRouterProvider.executeRequest({ ...baseRequest, context: 'CTX' })
const { messages } = mockCreate.mock.calls[0][0]
expect(messages[0]).toEqual({ role: 'system', content: 'You are helpful.' })
expect(messages[1]).toEqual({ role: 'user', content: 'CTX' })
expect(messages[2]).toEqual({ role: 'user', content: 'Hello' })
})
it('forwards maxTokens as max_tokens and temperature', async () => {
mockCreate.mockResolvedValueOnce(textResponse('ok'))
await openRouterProvider.executeRequest({ ...baseRequest, maxTokens: 256, temperature: 0.4 })
const payload = mockCreate.mock.calls[0][0]
expect(payload.max_tokens).toBe(256)
expect(payload.temperature).toBe(0.4)
})
it('runs the tool loop: executes the tool, echoes tool_calls, returns the tool result, sums tokens', async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse('get_weather', { city: 'SF' }))
.mockResolvedValueOnce(
textResponse('It is sunny', { prompt_tokens: 20, completion_tokens: 6, total_tokens: 26 })
)
mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } })
const res = (await openRouterProvider.executeRequest({
...baseRequest,
tools: [tool('get_weather')],
})) as ProviderResponse
expect(mockExecuteTool).toHaveBeenCalledWith('get_weather', { city: 'SF' }, expect.anything())
expect(res.content).toBe('It is sunny')
expect(res.toolCalls?.[0]).toMatchObject({
name: 'get_weather',
result: { temp: 70 },
success: true,
})
expect(res.toolResults).toEqual([{ temp: 70 }])
expect(res.tokens).toEqual({ input: 28, output: 10, total: 38 })
const secondMessages = mockCreate.mock.calls[1][0].messages
const assistant = secondMessages.find((m: { role: string }) => m.role === 'assistant')
expect(assistant).toMatchObject({
content: null,
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather' } }],
})
const toolMsg = secondMessages.find((m: { role: string }) => m.role === 'tool')
expect(toolMsg).toEqual({
role: 'tool',
tool_call_id: 'call_1',
content: JSON.stringify({ temp: 70 }),
})
})
it('reports a failed tool result as an error payload to the model', async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse('get_weather', { city: 'SF' }))
.mockResolvedValueOnce(textResponse('done'))
mockExecuteTool.mockResolvedValueOnce({ success: false, output: undefined, error: 'boom' })
const res = (await openRouterProvider.executeRequest({
...baseRequest,
tools: [tool('get_weather')],
})) as ProviderResponse
expect(res.toolResults).toBeUndefined()
expect(res.toolCalls?.[0]).toMatchObject({ success: false })
const toolMsg = mockCreate.mock.calls[1][0].messages.find(
(m: { role: string }) => m.role === 'tool'
)
expect(JSON.parse(toolMsg.content)).toEqual({
error: true,
message: 'boom',
tool: 'get_weather',
})
})
it('applies native structured outputs (json_schema + require_parameters) when no tools are active', async () => {
mockSupportsNative.mockResolvedValue(true)
mockCreate.mockResolvedValueOnce(textResponse('{"x":1}'))
await openRouterProvider.executeRequest({
...baseRequest,
responseFormat: {
name: 'out',
schema: { type: 'object', properties: { x: { type: 'number' } } },
strict: true,
},
})
const payload = mockCreate.mock.calls[0][0]
expect(payload.response_format).toMatchObject({
type: 'json_schema',
json_schema: { name: 'out', strict: true },
})
expect(payload.provider).toMatchObject({ require_parameters: true })
})
it('falls back to json_object + prompt instructions when native structured outputs are unsupported', async () => {
mockSupportsNative.mockResolvedValue(false)
mockCreate.mockResolvedValueOnce(textResponse('{"x":1}'))
await openRouterProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'out', schema: { type: 'object' } },
})
const payload = mockCreate.mock.calls[0][0]
expect(payload.response_format).toEqual({ type: 'json_object' })
expect(payload.messages.at(-1)).toEqual({ role: 'user', content: 'SCHEMA_INSTRUCTIONS' })
})
it('defers response_format until after the tool loop when tools are active', async () => {
mockSupportsNative.mockResolvedValue(true)
mockCreate
.mockResolvedValueOnce(textResponse('interim'))
.mockResolvedValueOnce(textResponse('{"x":1}'))
const res = (await openRouterProvider.executeRequest({
...baseRequest,
tools: [tool('get_weather')],
responseFormat: { name: 'out', schema: { type: 'object' }, strict: true },
})) as ProviderResponse
const toolCall = mockCreate.mock.calls[0][0]
expect(toolCall.tools).toBeDefined()
expect(toolCall.response_format).toBeUndefined()
const finalCall = mockCreate.mock.calls[1][0]
expect(finalCall.response_format).toMatchObject({ type: 'json_schema' })
expect(finalCall.tools).toBeUndefined()
expect(finalCall.tool_choice).toBeUndefined()
expect(res.content).toBe('{"x":1}')
})
it('forces the next tool after a forced tool is used', async () => {
mockPrepareTools.mockReturnValueOnce({
tools: [tool('a')],
toolChoice: { type: 'function', function: { name: 'a' } },
forcedTools: ['a', 'b'],
hasFilteredTools: false,
})
mockCheckForced.mockReturnValueOnce({ hasUsedForcedTool: true, usedForcedTools: ['a'] })
mockCreate
.mockResolvedValueOnce(toolCallResponse('a', {}))
.mockResolvedValueOnce(textResponse('done'))
mockExecuteTool.mockResolvedValueOnce({ success: true, output: {} })
await openRouterProvider.executeRequest({ ...baseRequest, tools: [tool('a'), tool('b')] })
expect(mockCreate.mock.calls[0][0].tool_choice).toEqual({
type: 'function',
function: { name: 'a' },
})
expect(mockCreate.mock.calls[1][0].tool_choice).toEqual({
type: 'function',
function: { name: 'b' },
})
})
it('streams directly when there are no tools and sends usage opt-in', async () => {
mockCreate.mockResolvedValueOnce({})
const res = await openRouterProvider.executeRequest({ ...baseRequest, stream: true })
const payload = mockCreate.mock.calls[0][0]
expect(payload.stream).toBe(true)
expect(payload.stream_options).toEqual({ include_usage: true })
expect(mockCreateStream).toHaveBeenCalledTimes(1)
expect(res).toHaveProperty('stream')
expect(res).toHaveProperty('execution.output.model', 'anthropic/claude-3.5-sonnet')
})
it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => {
mockCreate.mockResolvedValue(toolCallResponse('looping', {}))
mockExecuteTool.mockResolvedValue({ success: true, output: {} })
const res = (await openRouterProvider.executeRequest({
...baseRequest,
tools: [tool('looping')],
})) as ProviderResponse
expect(mockCreate).toHaveBeenCalledTimes(11)
expect(mockExecuteTool).toHaveBeenCalledTimes(10)
expect(res.toolCalls?.length).toBe(10)
})
it('wraps SDK errors in a ProviderError', async () => {
mockCreate.mockRejectedValueOnce(new Error('rate limited'))
await expect(openRouterProvider.executeRequest(baseRequest)).rejects.toThrow('rate limited')
})
})
+601
View File
@@ -0,0 +1,601 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import {
checkForForcedToolUsage,
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,
Message,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
generateSchemaInstructions,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('OpenRouterProvider')
/**
* Applies structured output configuration to a payload based on model capabilities.
* Uses json_schema with require_parameters for supported models, falls back to json_object with prompt instructions.
*/
async function applyResponseFormat(
targetPayload: any,
messages: any[],
responseFormat: any,
model: string
): Promise<any[]> {
const useNative = await supportsNativeStructuredOutputs(model)
if (useNative) {
logger.info('Using native structured outputs for OpenRouter model', { model })
targetPayload.response_format = {
type: 'json_schema',
json_schema: {
name: responseFormat.name || 'response_schema',
schema: responseFormat.schema || responseFormat,
strict: responseFormat.strict !== false,
},
}
targetPayload.provider = { ...targetPayload.provider, require_parameters: true }
return messages
}
logger.info('Using json_object mode with prompt instructions for OpenRouter model', { model })
const schema = responseFormat.schema || responseFormat
const schemaInstructions = generateSchemaInstructions(schema, responseFormat.name)
targetPayload.response_format = { type: 'json_object' }
return [...messages, { role: 'user', content: schemaInstructions }]
}
export const openRouterProvider: ProviderConfig = {
id: 'openrouter',
name: 'OpenRouter',
description: 'Unified access to many models via OpenRouter',
version: '1.0.0',
models: getProviderModels('openrouter'),
defaultModel: getProviderDefaultModel('openrouter'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for OpenRouter')
}
const client = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://openrouter.ai/api/v1',
})
const requestedModel = request.model.replace(/^openrouter\//, '')
logger.info('Preparing OpenRouter request', {
model: requestedModel,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({ role: 'system', content: request.systemPrompt })
}
if (request.context) {
allMessages.push({ role: 'user', content: request.context })
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'openrouter') as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: requestedModel,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openrouter')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.responseFormat && !hasActiveTools) {
payload.messages = await applyResponseFormat(
payload,
payload.messages,
request.responseFormat,
requestedModel
)
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 costResult = calculateCost(
requestedModel,
usage.prompt_tokens,
usage.completion_tokens
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await client.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls: FunctionCallResponse[] = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
const forcedToolResult = checkForForcedToolUsage(
currentResponse,
originalToolChoice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = forcedToolResult.hasUsedForcedTool
usedForcedTools = forcedToolResult.usedForcedTools
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'openrouter' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call (OpenRouter):', {
error: toError(error).message,
toolName,
})
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } }
} else {
nextPayload.tool_choice = 'auto'
}
}
const nextModelStartTime = Date.now()
currentResponse = await client.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const nextForcedToolResult = checkForForcedToolUsage(
currentResponse,
nextPayload.tool_choice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool
usedForcedTools = nextForcedToolResult.usedForcedTools
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'openrouter' }
)
}
if (request.stream) {
const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = {
...payload,
messages: [...currentMessages],
tool_choice: 'auto',
stream: true,
stream_options: { include_usage: true },
}
if (request.responseFormat) {
;(streamingParams as any).messages = await applyResponseFormat(
streamingParams as any,
streamingParams.messages,
request.responseFormat,
requestedModel
)
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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,
},
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
}
if (request.responseFormat && hasActiveTools) {
const finalPayload: any = {
model: payload.model,
messages: [...currentMessages],
}
if (payload.temperature !== undefined) {
finalPayload.temperature = payload.temperature
}
if (payload.max_tokens !== undefined) {
finalPayload.max_tokens = payload.max_tokens
}
finalPayload.messages = await applyResponseFormat(
finalPayload,
finalPayload.messages,
request.responseFormat,
requestedModel
)
const finalStartTime = Date.now()
const finalResponse = await client.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalEndTime = Date.now()
const finalDuration = finalEndTime - finalStartTime
timeSegments.push({
type: 'model',
name: 'Final structured response',
startTime: finalStartTime,
endTime: finalEndTime,
duration: finalDuration,
})
modelTime += finalDuration
if (finalResponse.choices[0]?.message?.content) {
content = finalResponse.choices[0].message.content
}
if (finalResponse.usage) {
tokens.input += finalResponse.usage.prompt_tokens || 0
tokens.output += finalResponse.usage.completion_tokens || 0
tokens.total += finalResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
finalResponse,
finalResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'openrouter' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: requestedModel,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
const errorDetails: Record<string, any> = {
error: toError(error).message,
duration: totalDuration,
}
if (error && typeof error === 'object') {
const err = error as any
if (err.status) errorDetails.status = err.status
if (err.code) errorDetails.code = err.code
if (err.type) errorDetails.type = err.type
if (err.error?.message) errorDetails.providerMessage = err.error.message
if (err.error?.metadata) errorDetails.metadata = err.error.metadata
}
logger.error('Error in OpenRouter request:', errorDetails)
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+107
View File
@@ -0,0 +1,107 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils'
const logger = createLogger('OpenRouterUtils')
interface OpenRouterModelData {
id: string
supported_parameters?: string[]
}
interface ModelCapabilities {
supportsStructuredOutputs: boolean
supportsTools: boolean
}
let modelCapabilitiesCache: Map<string, ModelCapabilities> | null = null
let cacheTimestamp = 0
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
async function fetchModelCapabilities(): Promise<Map<string, ModelCapabilities>> {
try {
const response = await fetch('https://openrouter.ai/api/v1/models', {
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
await response.text().catch(() => {})
logger.warn('Failed to fetch OpenRouter model capabilities', {
status: response.status,
})
return new Map()
}
const data = await response.json()
const capabilities = new Map<string, ModelCapabilities>()
for (const model of (data.data ?? []) as OpenRouterModelData[]) {
const supportedParams = model.supported_parameters ?? []
capabilities.set(model.id, {
supportsStructuredOutputs: supportedParams.includes('structured_outputs'),
supportsTools: supportedParams.includes('tools'),
})
}
logger.info('Cached OpenRouter model capabilities', {
modelCount: capabilities.size,
withStructuredOutputs: Array.from(capabilities.values()).filter(
(c) => c.supportsStructuredOutputs
).length,
})
return capabilities
} catch (error) {
logger.error('Error fetching OpenRouter model capabilities', {
error: toError(error).message,
})
return new Map()
}
}
/**
* Gets capabilities for a specific OpenRouter model.
* Fetches from API if cache is stale or empty.
*/
export async function getOpenRouterModelCapabilities(
modelId: string
): Promise<ModelCapabilities | null> {
const now = Date.now()
if (!modelCapabilitiesCache || now - cacheTimestamp > CACHE_TTL_MS) {
modelCapabilitiesCache = await fetchModelCapabilities()
cacheTimestamp = now
}
const normalizedId = modelId.replace(/^openrouter\//, '')
return modelCapabilitiesCache.get(normalizedId) ?? null
}
export async function supportsNativeStructuredOutputs(modelId: string): Promise<boolean> {
const capabilities = await getOpenRouterModelCapabilities(modelId)
return capabilities?.supportsStructuredOutputs ?? false
}
export function createReadableStreamFromOpenAIStream(
openaiStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(openaiStream, 'OpenRouter', onComplete)
}
export function checkForForcedToolUsage(
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } {
return checkForForcedToolUsageOpenAI(
response,
toolChoice,
'OpenRouter',
forcedTools,
usedForcedTools
)
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Providers the Pi Coding Agent can run with a single API key. This list is the
* single source of truth for both the cloud env-var mapping (Pi handler) and the
* Pi block's model dropdown (UI), so the block only offers Pi-runnable models.
*
* Excludes providers Pi's key-based flow can't drive: ones needing richer config
* (Vertex OAuth, Bedrock IAM, Azure endpoint+key) and base-URL providers
* (Ollama, vLLM, LiteLLM, Together, Baseten, Ollama Cloud).
*/
export const PI_SUPPORTED_PROVIDER_IDS = [
'anthropic',
'openai',
'google',
'xai',
'deepseek',
'mistral',
'groq',
'cerebras',
'openrouter',
] as const
export type PiSupportedProvider = (typeof PI_SUPPORTED_PROVIDER_IDS)[number]
const PI_SUPPORTED_PROVIDER_SET = new Set<string>(PI_SUPPORTED_PROVIDER_IDS)
/** Whether the Pi Coding Agent can run a given provider via a single API key. */
export function isPiSupportedProvider(providerId: string): providerId is PiSupportedProvider {
return PI_SUPPORTED_PROVIDER_SET.has(providerId)
}
+82
View File
@@ -0,0 +1,82 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { anthropicProvider } from '@/providers/anthropic'
import { azureAnthropicProvider } from '@/providers/azure-anthropic'
import { azureOpenAIProvider } from '@/providers/azure-openai'
import { basetenProvider } from '@/providers/baseten'
import { bedrockProvider } from '@/providers/bedrock'
import { cerebrasProvider } from '@/providers/cerebras'
import { deepseekProvider } from '@/providers/deepseek'
import { fireworksProvider } from '@/providers/fireworks'
import { googleProvider } from '@/providers/google'
import { groqProvider } from '@/providers/groq'
import { litellmProvider } from '@/providers/litellm'
import { metaProvider } from '@/providers/meta'
import { mistralProvider } from '@/providers/mistral'
import { nvidiaProvider } from '@/providers/nvidia'
import { ollamaProvider } from '@/providers/ollama'
import { ollamaCloudProvider } from '@/providers/ollama-cloud'
import { openaiProvider } from '@/providers/openai'
import { openRouterProvider } from '@/providers/openrouter'
import { sakanaProvider } from '@/providers/sakana'
import { togetherProvider } from '@/providers/together'
import type { ProviderConfig, ProviderId } from '@/providers/types'
import { vertexProvider } from '@/providers/vertex'
import { vllmProvider } from '@/providers/vllm'
import { xAIProvider } from '@/providers/xai'
import { zaiProvider } from '@/providers/zai'
const logger = createLogger('ProviderRegistry')
const providerRegistry: Record<ProviderId, ProviderConfig> = {
openai: openaiProvider,
anthropic: anthropicProvider,
'azure-anthropic': azureAnthropicProvider,
google: googleProvider,
vertex: vertexProvider,
deepseek: deepseekProvider,
xai: xAIProvider,
cerebras: cerebrasProvider,
groq: groqProvider,
sakana: sakanaProvider,
nvidia: nvidiaProvider,
meta: metaProvider,
zai: zaiProvider,
vllm: vllmProvider,
litellm: litellmProvider,
mistral: mistralProvider,
'azure-openai': azureOpenAIProvider,
openrouter: openRouterProvider,
fireworks: fireworksProvider,
together: togetherProvider,
baseten: basetenProvider,
ollama: ollamaProvider,
'ollama-cloud': ollamaCloudProvider,
bedrock: bedrockProvider,
}
export async function getProviderExecutor(
providerId: ProviderId
): Promise<ProviderConfig | undefined> {
const provider = providerRegistry[providerId]
if (!provider) {
logger.error(`Provider not found: ${providerId}`)
return undefined
}
return provider
}
export async function initializeProviders(): Promise<void> {
for (const [id, provider] of Object.entries(providerRegistry)) {
if (provider.initialize) {
try {
await provider.initialize()
logger.info(`Initialized provider: ${id}`)
} catch (error) {
logger.error(`Failed to initialize ${id} provider`, {
error: getErrorMessage(error, 'Unknown error'),
})
}
}
}
}
+632
View File
@@ -0,0 +1,632 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils'
import { createStreamingExecution } from '@/providers/streaming-execution'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import type {
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('SakanaProvider')
const SAKANA_BASE_URL = 'https://api.sakana.ai/v1'
export const sakanaProvider: ProviderConfig = {
id: 'sakana',
name: 'Sakana AI',
description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API",
version: '1.0.0',
models: getProviderModels('sakana'),
defaultModel: getProviderDefaultModel('sakana'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Sakana AI')
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const sakana = new OpenAI({
apiKey: request.apiKey,
baseURL: SAKANA_BASE_URL,
})
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'sakana')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
const responseFormatPayload = request.responseFormat
? {
type: 'json_schema' as const,
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
: undefined
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
logger.info('Sakana request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: 'unknown',
model: request.model,
})
}
}
// Structured output and tool calling cannot be sent together — OpenAI-compatible
// backends reject a request that carries both `response_format` and active
// `tools`/`tool_choice`. Defer the schema until after the tool loop completes.
const deferResponseFormat = !!responseFormatPayload && hasActiveTools
if (responseFormatPayload && !deferResponseFormat) {
payload.response_format = responseFormatPayload
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
logger.info('Using streaming response for Sakana request (no tools)')
const streamResponse = await sakana.chat.completions.create(
{
...payload,
stream: true,
stream_options: { include_usage: true },
},
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 }) =>
createReadableStreamFromSakanaStream(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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await sakana.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let hasUsedForcedTool = false
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
if (
typeof originalToolChoice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
originalToolChoice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'sakana' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
// Every tool_call in the assistant message must be answered by a matching
// `tool` message, or the next request violates the OpenAI message contract.
// Emit an error result for an unknown tool rather than dropping it.
if (!tool) {
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: `Tool "${toolName}" is not available`,
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await sakana.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
if (
typeof nextPayload.tool_choice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
nextPayload.tool_choice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'sakana' }
)
}
} catch (error) {
logger.error('Error in Sakana request:', { error })
throw error
}
if (request.stream) {
logger.info('Using streaming for final Sakana response after tool processing')
// The tool loop is complete: this final pass only produces the textual answer.
// Force `tool_choice: 'none'` so the model cannot emit fresh tool calls that the
// text-only stream adapter would silently drop.
const streamingPayload: any = {
...payload,
messages: currentMessages,
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
if (deferResponseFormat && responseFormatPayload) {
streamingPayload.response_format = responseFormatPayload
streamingPayload.parallel_tool_calls = false
}
const streamResponse = await sakana.chat.completions.create(
streamingPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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 }) =>
createReadableStreamFromSakanaStream(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
}
// Tools were active, so `response_format` was withheld from the loop. Make one final
// tool-free call to obtain the structured response now that the tool work is done.
if (deferResponseFormat && responseFormatPayload) {
logger.info('Applying deferred JSON schema response format after tool processing')
const finalFormatStartTime = Date.now()
const finalPayload: any = {
...payload,
messages: currentMessages,
response_format: responseFormatPayload,
tool_choice: 'none',
parallel_tool_calls: false,
}
currentResponse = await sakana.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalFormatEndTime = Date.now()
timeSegments.push({
type: 'model',
name: request.model,
startTime: finalFormatStartTime,
endTime: finalFormatEndTime,
duration: finalFormatEndTime - finalFormatStartTime,
})
modelTime += finalFormatEndTime - finalFormatStartTime
const formattedContent = currentResponse.choices[0]?.message?.content
if (formattedContent) {
content = formattedContent
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'sakana' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Sakana request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a Sakana AI streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromSakanaStream(
sakanaStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(sakanaStream, 'Sakana', onComplete)
}
@@ -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()
})
})
+196
View File
@@ -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<NormalizedBlockOutput['cost']>
/** Initial token slice written into `execution.output.tokens`. */
type TokenSlice = NonNullable<NormalizedBlockOutput['tokens']>
/**
* 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<NormalizedBlockOutput['providerTiming']> =
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
}
}
}
+231
View File
@@ -0,0 +1,231 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCreate,
mockSupportsNativeStructuredOutputs,
mockPrepareToolsWithUsageControl,
mockExecuteTool,
} = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockSupportsNativeStructuredOutputs: vi.fn(),
mockPrepareToolsWithUsageControl: vi.fn(),
mockExecuteTool: vi.fn(),
}))
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(
class {
chat = { completions: { create: mockCreate } }
}
),
}))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 }))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn().mockReturnValue([]),
getProviderDefaultModel: vi.fn().mockReturnValue('moonshotai/Kimi-K2-Instruct'),
}))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: vi.fn((messages) => messages),
}))
vi.mock('@/providers/together/utils', () => ({
supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs,
createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream),
checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })),
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0 }),
generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'),
prepareToolExecution: vi.fn(() => ({ toolParams: { x: 1 }, executionParams: { x: 1 } })),
prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl,
sumToolCosts: vi.fn().mockReturnValue(0),
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
import { togetherProvider } from '@/providers/together/index'
import { ProviderError } from '@/providers/types'
const textResponse = (content: string) => ({
choices: [{ message: { content, tool_calls: [] } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
})
const toolCallResponse = () => ({
choices: [
{
message: {
content: null,
tool_calls: [
{ id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } },
],
},
},
],
usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 },
})
const toolDef = {
id: 'my_tool',
name: 'my_tool',
description: '',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
}
const callBody = (index: number) => mockCreate.mock.calls[index][0]
const lastCallBody = () => mockCreate.mock.calls.at(-1)?.[0]
describe('togetherProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSupportsNativeStructuredOutputs.mockResolvedValue(true)
mockPrepareToolsWithUsageControl.mockImplementation((tools) => ({
tools,
toolChoice: 'auto',
forcedTools: [],
}))
mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } })
})
const baseRequest = {
model: 'together/moonshotai/Kimi-K2-Instruct',
systemPrompt: 'You are helpful.',
messages: [{ role: 'user' as const, content: 'Hello' }],
apiKey: 'together-test-key',
}
it('throws when the API key is missing', async () => {
await expect(
togetherProvider.executeRequest({ ...baseRequest, apiKey: undefined })
).rejects.toThrow('API key is required for Together AI')
})
it('strips only the leading together/ prefix from the model id', async () => {
mockCreate.mockResolvedValueOnce(textResponse('hi there'))
const result = await togetherProvider.executeRequest(baseRequest)
expect(lastCallBody().model).toBe('moonshotai/Kimi-K2-Instruct')
expect(result).toMatchObject({
content: 'hi there',
model: 'moonshotai/Kimi-K2-Instruct',
tokens: { input: 10, output: 5, total: 15 },
})
})
it('wraps API errors in a ProviderError', async () => {
mockCreate.mockRejectedValueOnce(new Error('boom'))
await expect(togetherProvider.executeRequest(baseRequest)).rejects.toBeInstanceOf(ProviderError)
})
it('streams directly when there are no tools', async () => {
mockCreate.mockResolvedValueOnce({})
const result = await togetherProvider.executeRequest({ ...baseRequest, stream: true })
expect(lastCallBody()).toMatchObject({ stream: true, stream_options: { include_usage: true } })
expect(result).toHaveProperty('stream')
expect(result).toHaveProperty('execution')
})
it('sends a json_schema response_format with no strict field', async () => {
mockCreate.mockResolvedValueOnce(textResponse('{}'))
await togetherProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' }, strict: true },
})
expect(lastCallBody().response_format).toEqual({
type: 'json_schema',
json_schema: { name: 'my_schema', schema: { type: 'object' } },
})
expect(lastCallBody().response_format.json_schema).not.toHaveProperty('strict')
})
it('falls back to json_object with prompt instructions when native is unsupported', async () => {
mockSupportsNativeStructuredOutputs.mockResolvedValue(false)
mockCreate.mockResolvedValueOnce(textResponse('{}'))
await togetherProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' } },
})
expect(lastCallBody().response_format).toEqual({ type: 'json_object' })
expect(lastCallBody().messages.at(-1)).toEqual({
role: 'user',
content: 'SCHEMA_INSTRUCTIONS',
})
})
it('defers response_format to a final call when tools are active', async () => {
mockCreate
.mockResolvedValueOnce(textResponse('intermediate'))
.mockResolvedValueOnce(textResponse('{"done":true}'))
await togetherProvider.executeRequest({
...baseRequest,
responseFormat: { name: 'my_schema', schema: { type: 'object' } },
tools: [toolDef],
})
expect(mockCreate).toHaveBeenCalledTimes(2)
expect(callBody(0).response_format).toBeUndefined()
expect(callBody(0).tools).toBeDefined()
expect(callBody(1).response_format).toEqual({
type: 'json_schema',
json_schema: { name: 'my_schema', schema: { type: 'object' } },
})
expect(callBody(1).tools).toBeUndefined()
})
it('runs the tool loop and threads tool results back into the conversation', async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse())
.mockResolvedValueOnce(textResponse('final answer'))
const result = await togetherProvider.executeRequest({ ...baseRequest, tools: [toolDef] })
expect(mockExecuteTool).toHaveBeenCalledWith('my_tool', { x: 1 }, expect.anything())
expect(result).toMatchObject({ content: 'final answer' })
expect((result as { toolCalls?: unknown[] }).toolCalls).toHaveLength(1)
const followUpMessages = callBody(1).messages
expect(followUpMessages).toContainEqual(
expect.objectContaining({ role: 'assistant', tool_calls: expect.any(Array) })
)
expect(followUpMessages).toContainEqual(
expect.objectContaining({ role: 'tool', tool_call_id: 'call_1' })
)
})
it("forces tool_choice 'none' on the final streaming call after tools run", async () => {
mockCreate
.mockResolvedValueOnce(toolCallResponse())
.mockResolvedValueOnce(textResponse('done'))
.mockResolvedValueOnce({})
await togetherProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] })
expect(mockCreate).toHaveBeenCalledTimes(3)
expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true })
})
})
+599
View File
@@ -0,0 +1,599 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { 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,
Message,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
generateSchemaInstructions,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import { executeTool } from '@/tools'
const logger = createLogger('TogetherProvider')
/**
* Applies structured output configuration to a payload based on model capabilities.
* Uses native json_schema for supported models, falls back to json_object with prompt instructions.
*/
async function applyResponseFormat(
targetPayload: any,
messages: any[],
responseFormat: any,
model: string
): Promise<any[]> {
const useNative = await supportsNativeStructuredOutputs(model)
if (useNative) {
logger.info('Using native structured outputs for Together model', { model })
targetPayload.response_format = {
type: 'json_schema',
json_schema: {
name: responseFormat.name || 'response_schema',
schema: responseFormat.schema || responseFormat,
},
}
return messages
}
logger.info('Using json_object mode with prompt instructions for Together model', { model })
const schema = responseFormat.schema || responseFormat
const schemaInstructions = generateSchemaInstructions(schema, responseFormat.name)
targetPayload.response_format = { type: 'json_object' }
return [...messages, { role: 'user', content: schemaInstructions }]
}
export const togetherProvider: ProviderConfig = {
id: 'together',
name: 'Together AI',
description: 'Fast inference for open-source models via Together AI',
version: '1.0.0',
models: getProviderModels('together'),
defaultModel: getProviderDefaultModel('together'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Together AI')
}
const client = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://api.together.ai/v1',
})
const requestedModel = request.model.replace(/^together\//, '')
logger.info('Preparing Together request', {
model: requestedModel,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({ role: 'system', content: request.systemPrompt })
}
if (request.context) {
allMessages.push({ role: 'user', content: request.context })
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'together') as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: requestedModel,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'together')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.responseFormat && !hasActiveTools) {
payload.messages = await applyResponseFormat(
payload,
payload.messages,
request.responseFormat,
requestedModel
)
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 costResult = calculateCost(
requestedModel,
usage.prompt_tokens,
usage.completion_tokens
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await client.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls: FunctionCallResponse[] = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
let hasUsedForcedTool = false
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
const forcedToolResult = checkForForcedToolUsage(
currentResponse,
originalToolChoice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = forcedToolResult.hasUsedForcedTool
usedForcedTools = forcedToolResult.usedForcedTools
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'together' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call (Together):', {
error: toError(error).message,
toolName,
})
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success) {
toolResults.push(result.output!)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = { type: 'function', function: { name: remainingTools[0] } }
} else {
nextPayload.tool_choice = 'auto'
}
}
const nextModelStartTime = Date.now()
currentResponse = await client.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const nextForcedToolResult = checkForForcedToolUsage(
currentResponse,
nextPayload.tool_choice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = nextForcedToolResult.hasUsedForcedTool
usedForcedTools = nextForcedToolResult.usedForcedTools
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'together' }
)
}
if (request.stream) {
const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: [...currentMessages],
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
if (request.responseFormat) {
;(streamingParams as any).messages = await applyResponseFormat(
streamingParams as any,
streamingParams.messages,
request.responseFormat,
requestedModel
)
}
const streamResponse = await client.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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,
},
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
}
if (request.responseFormat && hasActiveTools) {
const finalPayload: any = {
model: payload.model,
messages: [...currentMessages],
}
if (payload.temperature !== undefined) {
finalPayload.temperature = payload.temperature
}
if (payload.max_tokens !== undefined) {
finalPayload.max_tokens = payload.max_tokens
}
finalPayload.messages = await applyResponseFormat(
finalPayload,
finalPayload.messages,
request.responseFormat,
requestedModel
)
const finalStartTime = Date.now()
const finalResponse = await client.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalEndTime = Date.now()
const finalDuration = finalEndTime - finalStartTime
timeSegments.push({
type: 'model',
name: 'Final structured response',
startTime: finalStartTime,
endTime: finalEndTime,
duration: finalDuration,
})
modelTime += finalDuration
if (finalResponse.choices[0]?.message?.content) {
content = finalResponse.choices[0].message.content
}
if (finalResponse.usage) {
tokens.input += finalResponse.usage.prompt_tokens || 0
tokens.output += finalResponse.usage.completion_tokens || 0
tokens.total += finalResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
finalResponse,
finalResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'together' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: requestedModel,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
const errorDetails: Record<string, any> = {
error: toError(error).message,
duration: totalDuration,
}
if (error && typeof error === 'object') {
const err = error as any
if (err.status) errorDetails.status = err.status
if (err.code) errorDetails.code = err.code
if (err.type) errorDetails.type = err.type
if (err.error?.message) errorDetails.providerMessage = err.error.message
if (err.error?.metadata) errorDetails.metadata = err.error.metadata
}
logger.error('Error in Together request:', errorDetails)
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+41
View File
@@ -0,0 +1,41 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils'
/**
* Together gates native `json_schema` per-model, so we use the broadly supported
* JSON-object mode for all models to avoid 400s. See https://docs.together.ai/docs/json-mode.
*/
export async function supportsNativeStructuredOutputs(_modelId: string): Promise<boolean> {
return false
}
/**
* Creates a ReadableStream from a Together AI streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromOpenAIStream(
openaiStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(openaiStream, 'Together', onComplete)
}
/**
* Checks if a forced tool was used in a Together AI response.
* Uses the shared OpenAI-compatible forced tool usage helper.
*/
export function checkForForcedToolUsage(
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } {
return checkForForcedToolUsageOpenAI(
response,
toolChoice,
'Together',
forcedTools,
usedForcedTools
)
}
@@ -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,
},
})
})
})
+56
View File
@@ -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,
},
}
}
+221
View File
@@ -0,0 +1,221 @@
import type { BlockTokens, IterationToolCall, ProviderTimingSegment } from '@/executor/types'
import { calculateCost } from '@/providers/utils'
/**
* Minimal structural shape shared by OpenAI Chat Completions and every
* OpenAI-compatible SDK (Groq, Cerebras, DeepSeek, xAI, Mistral, Ollama,
* OpenRouter, vLLM, Fireworks). Captures only the fields the trace enrichment
* helper reads, so providers can pass their own SDK's response type without
* a cast.
*/
interface ChatCompletionLike {
choices: Array<{
message?: {
content?: string | null
tool_calls?: Array<ChatCompletionToolCallLike> | null
} | null
finish_reason?: string | null
} | null>
usage?: {
prompt_tokens?: number | null
completion_tokens?: number | null
total_tokens?: number | null
prompt_tokens_details?: { cached_tokens?: number | null } | null
completion_tokens_details?: { reasoning_tokens?: number | null } | null
/** DeepSeek's legacy cache shape (not nested under prompt_tokens_details). */
prompt_cache_hit_tokens?: number | null
} | null
}
interface ChatCompletionToolCallLike {
id: string
function: { name: string; arguments: string }
}
/**
* Content to attach to a model segment for a single provider iteration.
* All fields are optional — providers populate what the response carries.
*/
export interface ModelSegmentContent {
assistantContent?: string
thinkingContent?: string
toolCalls?: IterationToolCall[]
finishReason?: string
tokens?: BlockTokens
cost?: { input?: number; output?: number; total?: number }
ttft?: number
provider?: string
errorType?: string
errorMessage?: string
}
/**
* Enriches the most recent `type: 'model'` segment in `timeSegments` with
* content from the model response for that iteration. Writes only the fields
* provided; undefined fields are skipped so repeat calls can layer data.
*
* Call at the point where the response for the latest model segment is in hand
* — typically right after the provider call returns, before tool execution.
*/
export function enrichLastModelSegment(
timeSegments: ProviderTimingSegment[],
content: ModelSegmentContent
): void {
for (let i = timeSegments.length - 1; i >= 0; i--) {
const segment = timeSegments[i]
if (segment.type !== 'model') continue
if (content.assistantContent !== undefined) {
segment.assistantContent = content.assistantContent
}
if (content.thinkingContent !== undefined) {
segment.thinkingContent = content.thinkingContent
}
if (content.toolCalls !== undefined) {
segment.toolCalls = content.toolCalls
}
if (content.finishReason !== undefined) {
segment.finishReason = content.finishReason
}
if (content.tokens !== undefined) {
segment.tokens = content.tokens
}
if (content.cost !== undefined) {
segment.cost = content.cost
}
if (content.ttft !== undefined) {
segment.ttft = content.ttft
}
if (content.provider !== undefined) {
segment.provider = content.provider
}
if (content.errorType !== undefined) {
segment.errorType = content.errorType
}
if (content.errorMessage !== undefined) {
segment.errorMessage = content.errorMessage
}
return
}
}
/**
* Parses a tool call's `function.arguments` JSON string into an object, or
* returns the raw string if it is not valid JSON.
*/
function parseToolCallArguments(rawArguments: string): Record<string, unknown> | string {
try {
const parsed = JSON.parse(rawArguments)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
return rawArguments
} catch {
return rawArguments
}
}
/**
* Extracts reasoning/thinking content from a Chat Completions message. Covers
* non-OpenAI extensions emitted by reasoning-capable providers:
* - `reasoning_content`: DeepSeek, xAI, vLLM, Fireworks
* - `reasoning`: Groq, Cerebras, OpenRouter (flat)
* - `reasoning_details[]`: OpenRouter (structured per-block reasoning)
*/
function extractChatCompletionsReasoning(
message: NonNullable<ChatCompletionLike['choices'][number]>['message']
): string | undefined {
if (!message) return undefined
const msg = message as unknown as {
reasoning_content?: string | null
reasoning?: string | null
reasoning_details?: Array<{ text?: string | null; summary?: string | null } | null> | null
}
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content.length > 0) {
return msg.reasoning_content
}
if (typeof msg.reasoning === 'string' && msg.reasoning.length > 0) {
return msg.reasoning
}
if (Array.isArray(msg.reasoning_details)) {
const joined = msg.reasoning_details
.map((d) => d?.text ?? d?.summary ?? '')
.filter((s): s is string => typeof s === 'string' && s.length > 0)
.join('\n')
if (joined.length > 0) return joined
}
return undefined
}
/**
* Enriches the last model segment with per-iteration content from a Chat
* Completions response: assistant text, thinking/reasoning, tool calls, finish
* reason, token usage. Shared by all OpenAI-compat providers.
*/
export function enrichLastModelSegmentFromChatCompletions(
timeSegments: ProviderTimingSegment[],
response: ChatCompletionLike,
toolCallsInResponse: ChatCompletionToolCallLike[] | undefined,
extras?: {
/** Model id used for this call — enables automatic cost calculation. */
model?: string
/** Provider system identifier (`gen_ai.system`). */
provider?: string
/** Time-to-first-token in ms (streaming path only). */
ttft?: number
/** Structured error class when the call failed. */
errorType?: string
/** Human-readable error message when the call failed. */
errorMessage?: string
/** Override the automatically derived cost. */
cost?: { input?: number; output?: number; total?: number }
}
): void {
const choice = response.choices[0]
const assistantText = choice?.message?.content ?? ''
const thinkingText = extractChatCompletionsReasoning(choice?.message)
const toolCalls: IterationToolCall[] = (toolCallsInResponse ?? []).map((tc) => ({
id: tc.id,
name: tc.function.name,
arguments: parseToolCallArguments(tc.function.arguments),
}))
const usage = response.usage
const cacheRead =
usage?.prompt_tokens_details?.cached_tokens ?? usage?.prompt_cache_hit_tokens ?? 0
const reasoning = usage?.completion_tokens_details?.reasoning_tokens ?? 0
const promptTokens = usage?.prompt_tokens ?? undefined
const completionTokens = usage?.completion_tokens ?? undefined
let derivedCost = extras?.cost
if (!derivedCost && extras?.model && promptTokens != null && completionTokens != null) {
const full = calculateCost(extras.model, promptTokens, completionTokens, cacheRead > 0)
derivedCost = { input: full.input, output: full.output, total: full.total }
}
enrichLastModelSegment(timeSegments, {
assistantContent: assistantText || undefined,
thinkingContent: thinkingText,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
finishReason: choice?.finish_reason ?? undefined,
tokens: usage
? {
input: promptTokens,
output: completionTokens,
total: usage.total_tokens ?? undefined,
...(cacheRead > 0 && { cacheRead }),
...(reasoning > 0 && { reasoning }),
}
: undefined,
cost: derivedCost,
ttft: extras?.ttft,
provider: extras?.provider,
errorType: extras?.errorType,
errorMessage: extras?.errorMessage,
})
}
export { parseToolCallArguments }
+210
View File
@@ -0,0 +1,210 @@
import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types'
export type ProviderId =
| 'openai'
| 'azure-openai'
| 'anthropic'
| 'azure-anthropic'
| 'google'
| 'vertex'
| 'deepseek'
| 'xai'
| 'cerebras'
| 'groq'
| 'sakana'
| 'nvidia'
| 'meta'
| 'zai'
| 'mistral'
| 'ollama'
| 'ollama-cloud'
| 'openrouter'
| 'fireworks'
| 'together'
| 'baseten'
| 'vllm'
| 'litellm'
| 'bedrock'
export interface ModelPricing {
input: number // Per 1M tokens
cachedInput?: number // Per 1M tokens (if supported)
output: number // Per 1M tokens
updatedAt: string // Last updated date
}
export type ModelPricingMap = Record<string, ModelPricing>
interface TokenInfo {
input?: number
output?: number
total?: number
}
interface TransformedResponse {
content: string
tokens?: TokenInfo
}
export interface ProviderConfig {
id: string
name: string
description: string
version: string
models: string[]
defaultModel: string
initialize?: () => Promise<void>
executeRequest: (
request: ProviderRequest
) => Promise<ProviderResponse | ReadableStream<any> | StreamingExecution>
}
export interface FunctionCallResponse {
name: string
arguments: Record<string, any>
startTime?: string
endTime?: string
duration?: number
result?: Record<string, any>
output?: Record<string, any>
input?: Record<string, any>
success?: boolean
}
/**
* Provider-side alias for the canonical segment type. Providers push these into
* `providerTiming.timeSegments` during execution; the trace pipeline reads them
* verbatim when constructing child spans.
*/
export type TimeSegment = ProviderTimingSegment
export interface ProviderResponse {
content: string
model: string
tokens?: {
input?: number
output?: number
total?: number
}
toolCalls?: FunctionCallResponse[]
toolResults?: Record<string, unknown>[]
timing?: {
startTime: string
endTime: string
duration: number
modelTime?: number
toolsTime?: number
firstResponseTime?: number
iterations?: number
timeSegments?: TimeSegment[]
}
cost?: {
input: number
output: number
toolCost?: number
total: number
pricing: ModelPricing
}
/** Interaction ID returned by the Interactions API (used for multi-turn deep research) */
interactionId?: string
}
export type ToolUsageControl = 'auto' | 'force' | 'none'
export interface ProviderToolConfig {
id: string
name: string
description: string
params: Record<string, any>
parameters: {
type: string
properties: Record<string, any>
required: string[]
}
usageControl?: ToolUsageControl
/** Block-level params transformer — converts SubBlock values to tool-ready params */
paramsTransform?: (params: Record<string, any>) => Record<string, any>
}
export interface Message {
role: 'system' | 'user' | 'assistant' | 'function' | 'tool'
content: string | null
files?: UserFile[]
name?: string
function_call?: {
name: string
arguments: string
}
tool_calls?: Array<{
id: string
type: 'function'
function: {
name: string
arguments: string
}
}>
tool_call_id?: string
}
export interface ProviderRequest {
model: string
systemPrompt?: string
context?: string
tools?: ProviderToolConfig[]
temperature?: number
maxTokens?: number
apiKey?: string
messages?: Message[]
responseFormat?: {
name: string
schema: any
strict?: boolean
}
local_execution?: boolean
workflowId?: string
workspaceId?: string
chatId?: string
userId?: string
stream?: boolean
streamToolCalls?: boolean
environmentVariables?: Record<string, string>
workflowVariables?: Record<string, any>
blockData?: Record<string, any>
blockNameMapping?: Record<string, string>
isCopilotRequest?: boolean
isBYOK?: boolean
azureEndpoint?: string
azureApiVersion?: string
vertexProject?: string
vertexLocation?: string
bedrockAccessKeyId?: string
bedrockSecretKey?: string
bedrockRegion?: string
reasoningEffort?: string
verbosity?: string
thinkingLevel?: string
isDeployedContext?: boolean
callChain?: string[]
/** Previous interaction ID for multi-turn Interactions API requests (deep research follow-ups) */
previousInteractionId?: string
abortSignal?: AbortSignal
}
/**
* Typed error class for provider failures that includes timing information.
*/
export class ProviderError extends Error {
timing: {
startTime: string
endTime: string
duration: number
}
constructor(message: string, timing: { startTime: string; endTime: string; duration: number }) {
super(message)
this.name = 'ProviderError'
this.timing = timing
}
}
export const providers: Record<string, ProviderConfig> = {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
import { GoogleGenAI } from '@google/genai'
import { createLogger } from '@sim/logger'
import { OAuth2Client } from 'google-auth-library'
import { env } from '@/lib/core/config/env'
import type { StreamingExecution } from '@/executor/types'
import { executeGeminiRequest } from '@/providers/gemini/core'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types'
const logger = createLogger('VertexProvider')
/**
* Vertex AI provider
*
* Uses the @google/genai SDK with Vertex AI backend and OAuth authentication.
* Shares core execution logic with Google Gemini provider.
*
* Authentication:
* - Uses OAuth access token passed via googleAuthOptions.authClient
* - Token refresh is handled at the OAuth layer before calling this provider
*/
export const vertexProvider: ProviderConfig = {
id: 'vertex',
name: 'Vertex AI',
description: "Google's Vertex AI platform for Gemini models",
version: '1.0.0',
models: getProviderModels('vertex'),
defaultModel: getProviderDefaultModel('vertex'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
const vertexProject = request.vertexProject || env.VERTEX_PROJECT
const vertexLocation = request.vertexLocation || env.VERTEX_LOCATION || 'us-central1'
if (!vertexProject) {
throw new Error(
'Vertex AI project is required. Please provide it via VERTEX_PROJECT environment variable or vertexProject parameter.'
)
}
if (!request.apiKey) {
throw new Error(
'Access token is required for Vertex AI. Run `gcloud auth print-access-token` to get one, or use a service account.'
)
}
// Strip 'vertex/' prefix from model name if present
const model = request.model.replace('vertex/', '')
logger.info('Creating Vertex AI client', {
project: vertexProject,
location: vertexLocation,
model,
})
// Create an OAuth2Client and set the access token
// This allows us to use an OAuth access token with the SDK
const authClient = new OAuth2Client()
authClient.setCredentials({ access_token: request.apiKey })
// Create client with Vertex AI configuration
const ai = new GoogleGenAI({
vertexai: true,
project: vertexProject,
location: vertexLocation,
googleAuthOptions: {
authClient,
},
})
return executeGeminiRequest({
ai,
model,
request,
providerType: 'vertex',
})
},
}
+398
View File
@@ -0,0 +1,398 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCreate,
openAIArgs,
mockOpenAI,
mockExecuteTool,
mockPrepareTools,
mockCheckForced,
mockCreateStream,
mockValidateUrlWithDNS,
mockCreatePinnedFetch,
pinnedFetchFn,
envState,
} = vi.hoisted(() => {
const openAIArgs: Array<Record<string, unknown>> = []
const mockCreate = vi.fn()
const pinnedFetchFn = vi.fn()
class MockOpenAI {
chat = { completions: { create: mockCreate } }
constructor(opts: Record<string, unknown>) {
openAIArgs.push(opts)
}
}
return {
mockCreate,
openAIArgs,
mockOpenAI: MockOpenAI,
mockExecuteTool: vi.fn(),
mockPrepareTools: vi.fn(),
mockCheckForced: vi.fn(),
mockCreateStream: vi.fn(),
mockValidateUrlWithDNS: vi.fn(),
mockCreatePinnedFetch: vi.fn(() => pinnedFetchFn),
pinnedFetchFn,
envState: {
VLLM_BASE_URL: 'http://localhost:8000',
VLLM_API_KEY: undefined as string | undefined,
},
}
})
vi.mock('openai', () => ({ default: mockOpenAI }))
vi.mock('@/lib/core/config/env', () => ({ env: envState }))
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: mockValidateUrlWithDNS,
createPinnedFetch: mockCreatePinnedFetch,
}))
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 }))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getProviderModels: vi.fn(() => []),
getProviderDefaultModel: vi.fn(() => 'vllm/generic'),
}))
vi.mock('@/providers/attachments', () => ({
formatMessagesForProvider: vi.fn((messages) => messages),
}))
vi.mock('@/providers/trace-enrichment', () => ({
enrichLastModelSegmentFromChatCompletions: vi.fn(),
}))
vi.mock('@/providers/utils', () => ({
calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })),
prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })),
prepareToolsWithUsageControl: mockPrepareTools,
sumToolCosts: vi.fn(() => 0),
}))
vi.mock('@/providers/vllm/utils', () => ({
checkForForcedToolUsage: mockCheckForced,
createReadableStreamFromVLLMStream: mockCreateStream,
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
vi.mock('@/stores/providers', () => ({
useProvidersStore: { getState: () => ({ setProviderModels: vi.fn() }) },
}))
import { clearProviderClientCacheForTests } from '@/providers/client-cache'
import type { ProviderToolConfig } from '@/providers/types'
import { vllmProvider } from '@/providers/vllm/index'
interface ToolCall {
id: string
type: 'function'
function: { name: string; arguments: string }
}
function chatResponse(content: string | null, toolCalls?: ToolCall[]) {
return {
choices: [{ message: { content, tool_calls: toolCalls } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
}
function makeTool(id: string): ProviderToolConfig {
return {
id,
name: id,
description: '',
params: {},
parameters: { type: 'object', properties: {}, required: [] },
}
}
const toolCall = (id: string, name: string, args = '{}'): ToolCall => ({
id,
type: 'function',
function: { name, arguments: args },
})
/** Payload passed to the Nth `chat.completions.create` call. */
const createPayload = (callIndex: number) => mockCreate.mock.calls[callIndex][0]
describe('vllmProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
clearProviderClientCacheForTests()
openAIArgs.length = 0
envState.VLLM_BASE_URL = 'http://localhost:8000'
envState.VLLM_API_KEY = undefined
mockPrepareTools.mockReturnValue({
tools: [{ type: 'function', function: { name: 'myTool' } }],
toolChoice: 'auto',
forcedTools: [],
hasFilteredTools: false,
})
mockCheckForced.mockReturnValue({ hasUsedForcedTool: false, usedForcedTools: [] })
mockCreateStream.mockReturnValue(new ReadableStream({ start: (c) => c.close() }))
mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } })
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' })
mockCreatePinnedFetch.mockReturnValue(pinnedFetchFn)
})
describe('endpoint SSRF protection', () => {
it('does not validate or pin when no endpoint is supplied (uses env base URL)', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('hi'))
await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
})
expect(mockValidateUrlWithDNS).not.toHaveBeenCalled()
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(openAIArgs[0].baseURL).toBe('http://localhost:8000/v1')
expect(openAIArgs[0].fetch).toBeUndefined()
})
it('validates a user-supplied endpoint and pins the connection to the resolved IP', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('hi'))
await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
azureEndpoint: 'https://my-vllm.example.com',
})
expect(mockValidateUrlWithDNS).toHaveBeenCalledWith(
'https://my-vllm.example.com',
'vLLM endpoint',
{ allowHttp: true }
)
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
expect(openAIArgs[0].baseURL).toBe('https://my-vllm.example.com/v1')
expect(openAIArgs[0].fetch).toBe(pinnedFetchFn)
})
it('rejects a user-supplied endpoint that fails SSRF validation without issuing a request', async () => {
mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'vLLM endpoint resolves to a blocked IP address',
})
await expect(
vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
azureEndpoint: 'http://169.254.169.254',
})
).rejects.toThrow('Invalid vLLM endpoint')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(openAIArgs).toHaveLength(0)
expect(mockCreate).not.toHaveBeenCalled()
})
it('rejects a validated endpoint that did not resolve to a pinnable IP', async () => {
mockValidateUrlWithDNS.mockResolvedValueOnce({ isValid: true })
await expect(
vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
azureEndpoint: 'https://my-vllm.example.com',
})
).rejects.toThrow('could not resolve a pinnable IP address')
expect(mockCreatePinnedFetch).not.toHaveBeenCalled()
expect(openAIArgs).toHaveLength(0)
expect(mockCreate).not.toHaveBeenCalled()
})
})
it('builds a chat payload with the vllm/ prefix stripped and messages assembled in order', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('hello'))
const result = await vllmProvider.executeRequest({
model: 'vllm/llama-3',
systemPrompt: 'be helpful',
context: 'prior context',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.7,
maxTokens: 256,
})
const payload = createPayload(0)
expect(payload.model).toBe('llama-3')
expect(payload.temperature).toBe(0.7)
expect(payload.max_completion_tokens).toBe(256)
expect(payload.messages.map((m: { role: string }) => m.role)).toEqual([
'system',
'user',
'user',
])
expect(result.content).toBe('hello')
expect(result.tokens).toEqual({ input: 10, output: 5, total: 15 })
})
it('sends response_format as json_schema with strict when a responseFormat is provided', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('{}'))
await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
responseFormat: { name: 'out', schema: { type: 'object' }, strict: true },
})
expect(createPayload(0).response_format).toEqual({
type: 'json_schema',
json_schema: { name: 'out', schema: { type: 'object' }, strict: true },
})
})
it('strips markdown code fences from structured-output content', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('```json\n{"a":1}\n```'))
const result = await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
responseFormat: { name: 'out', schema: { type: 'object' }, strict: true },
})
expect(result.content).toBe('{"a":1}')
})
it('runs the tool loop: executes tools, appends assistant + tool messages, returns results', async () => {
mockCreate
.mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool', '{"x":1}')]))
.mockResolvedValueOnce(chatResponse('final answer'))
const result = await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'use a tool' }],
tools: [makeTool('myTool')],
})
expect(mockExecuteTool).toHaveBeenCalledWith('myTool', { x: 1 }, expect.anything())
const [assistantMessage, toolMessage] = createPayload(1).messages.slice(-2)
expect(assistantMessage).toMatchObject({
role: 'assistant',
content: null,
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'myTool' } }],
})
expect(toolMessage).toMatchObject({ role: 'tool', tool_call_id: 'call_1' })
expect(toolMessage).not.toHaveProperty('name')
expect(result.content).toBe('final answer')
expect(result.toolCalls).toHaveLength(1)
expect(result.toolCalls?.[0]).toMatchObject({ name: 'myTool', success: true })
expect(result.toolResults).toHaveLength(1)
})
it('records a failed tool result without throwing', async () => {
mockExecuteTool.mockResolvedValueOnce({ success: false, error: 'tool blew up' })
mockCreate
.mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool')]))
.mockResolvedValueOnce(chatResponse('done'))
const result = await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'go' }],
tools: [makeTool('myTool')],
})
expect(result.toolCalls?.[0]).toMatchObject({ name: 'myTool', success: false })
const toolMessage = createPayload(1).messages.at(-1)
expect(JSON.parse(toolMessage.content)).toMatchObject({ error: true, tool: 'myTool' })
})
it('surfaces a ProviderError when a follow-up model call fails mid-loop', async () => {
mockCreate
.mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool')]))
.mockRejectedValueOnce(new Error('connection reset'))
await expect(
vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'go' }],
tools: [makeTool('myTool')],
})
).rejects.toThrow('connection reset')
expect(mockExecuteTool).toHaveBeenCalledTimes(1)
})
it('cycles forced tools: forces the next forced tool after the first is used', async () => {
mockPrepareTools.mockReturnValue({
tools: [{ type: 'function', function: { name: 'toolA' } }],
toolChoice: { type: 'function', function: { name: 'toolA' } },
forcedTools: ['toolA', 'toolB'],
hasFilteredTools: false,
})
mockCheckForced
.mockReturnValueOnce({ hasUsedForcedTool: true, usedForcedTools: ['toolA'] })
.mockReturnValueOnce({ hasUsedForcedTool: true, usedForcedTools: ['toolA', 'toolB'] })
mockCreate
.mockResolvedValueOnce(chatResponse(null, [toolCall('c1', 'toolA')]))
.mockResolvedValueOnce(chatResponse('done'))
await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'go' }],
tools: [makeTool('toolA'), makeTool('toolB')],
})
expect(createPayload(1).tool_choice).toEqual({ type: 'function', function: { name: 'toolB' } })
})
it('streams directly when there are no tools, requesting usage in the stream', async () => {
mockCreate.mockResolvedValueOnce({})
const result = await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
})
expect(mockCreate).toHaveBeenCalledTimes(1)
const payload = createPayload(0)
expect(payload.stream).toBe(true)
expect(payload.stream_options).toEqual({ include_usage: true })
expect('stream' in result && 'execution' in result).toBe(true)
})
it('uses tool_choice "none" on the final streaming call after tool processing', async () => {
mockCreate.mockResolvedValueOnce(chatResponse('answer')).mockResolvedValueOnce({})
await vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
tools: [makeTool('myTool')],
})
const streamingPayload = createPayload(1)
expect(streamingPayload.stream).toBe(true)
expect(streamingPayload.tool_choice).toBe('none')
})
it('throws a ProviderError carrying the vLLM error message on API failure', async () => {
mockCreate.mockRejectedValueOnce({
error: { message: 'bad request', type: 'invalid', code: 400 },
})
await expect(
vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
})
).rejects.toThrow('bad request')
})
it('throws when no base URL is configured', async () => {
envState.VLLM_BASE_URL = ''
await expect(
vllmProvider.executeRequest({
model: 'vllm/llama-3',
messages: [{ role: 'user', content: 'hi' }],
})
).rejects.toThrow('VLLM_BASE_URL is required')
})
})
+670
View File
@@ -0,0 +1,670 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import { env } from '@/lib/core/config/env'
import { createPinnedFetch, validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import { getCachedProviderClient } from '@/providers/client-cache'
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,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import { checkForForcedToolUsage, createReadableStreamFromVLLMStream } from '@/providers/vllm/utils'
import { useProvidersStore } from '@/stores/providers'
import { executeTool } from '@/tools'
const logger = createLogger('VLLMProvider')
const VLLM_VERSION = '1.0.0'
export const vllmProvider: ProviderConfig = {
id: 'vllm',
name: 'vLLM',
description: 'Self-hosted vLLM with OpenAI-compatible API',
version: VLLM_VERSION,
models: getProviderModels('vllm'),
defaultModel: getProviderDefaultModel('vllm'),
async initialize() {
if (typeof window !== 'undefined') {
logger.info('Skipping vLLM initialization on client side to avoid CORS issues')
return
}
const baseUrl = (env.VLLM_BASE_URL || '').replace(/\/$/, '')
if (!baseUrl) {
logger.info('VLLM_BASE_URL not configured, skipping initialization')
return
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (env.VLLM_API_KEY) {
headers.Authorization = `Bearer ${env.VLLM_API_KEY}`
}
const response = await fetch(`${baseUrl}/v1/models`, { headers })
if (!response.ok) {
await response.text().catch(() => {})
useProvidersStore.getState().setProviderModels('vllm', [])
logger.warn('vLLM service is not available. The provider will be disabled.')
return
}
const data = (await response.json()) as { data: Array<{ id: string }> }
const models = data.data.map((model) => `vllm/${model.id}`)
this.models = models
useProvidersStore.getState().setProviderModels('vllm', models)
logger.info(`Discovered ${models.length} vLLM model(s):`, { models })
} catch (error) {
logger.warn('vLLM model instantiation failed. The provider will be disabled.', {
error: getErrorMessage(error, 'Unknown error'),
})
}
},
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
logger.info('Preparing vLLM request', {
model: request.model,
hasSystemPrompt: !!request.systemPrompt,
hasMessages: !!request.messages?.length,
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
stream: !!request.stream,
})
const userProvidedEndpoint = request.azureEndpoint
const baseUrl = (userProvidedEndpoint || env.VLLM_BASE_URL || '').replace(/\/$/, '')
if (!baseUrl) {
throw new Error('VLLM_BASE_URL is required for vLLM provider')
}
/**
* A user-supplied endpoint is attacker-controlled: validate it against the
* central SSRF guard and pin the connection to the resolved IP to defeat DNS
* rebinding. The operator-configured `VLLM_BASE_URL` is trusted and left
* unvalidated, mirroring the Azure providers.
*
* `allowHttp` is enabled because self-hosted vLLM is frequently served over
* plain HTTP; this only relaxes the protocol requirement — the private/reserved
* IP blocklist and blocked-port checks still apply, so SSRF protection is intact.
*/
let pinnedFetch: typeof fetch | undefined
let pinnedIP: string | undefined
if (userProvidedEndpoint) {
const validation = await validateUrlWithDNS(userProvidedEndpoint, 'vLLM endpoint', {
allowHttp: true,
})
if (!validation.isValid) {
logger.warn('Blocked SSRF attempt via vLLM endpoint', {
endpoint: userProvidedEndpoint,
error: validation.error,
})
throw new Error(`Invalid vLLM endpoint: ${validation.error}`)
}
if (!validation.resolvedIP) {
throw new Error('Invalid vLLM endpoint: could not resolve a pinnable IP address')
}
pinnedIP = validation.resolvedIP
pinnedFetch = createPinnedFetch(pinnedIP)
}
const apiKey = request.apiKey || env.VLLM_API_KEY || 'empty'
const vllm = getCachedProviderClient(
`vllm::${apiKey}::${baseUrl}::${pinnedIP ?? 'no-pin'}`,
() =>
new OpenAI({
apiKey,
baseURL: `${baseUrl}/v1`,
...(pinnedFetch ? { fetch: pinnedFetch } : {}),
})
)
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'vllm') as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model.replace(/^vllm\//, ''),
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
if (request.responseFormat) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: request.responseFormat.name || 'response_schema',
schema: request.responseFormat.schema || request.responseFormat,
strict: request.responseFormat.strict !== false,
},
}
logger.info('Added JSON schema response format to vLLM request')
}
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'vllm')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = toolChoice
hasActiveTools = true
logger.info('vLLM request configuration:', {
toolCount: filteredTools.length,
toolChoice:
typeof toolChoice === 'string'
? toolChoice
: toolChoice.type === 'function'
? `force:${toolChoice.function.name}`
: 'unknown',
model: payload.model,
})
}
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
logger.info('Using streaming response for vLLM request')
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await vllm.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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()
}
output.content = cleanContent
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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
finalizeTiming()
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let hasUsedForcedTool = false
let currentResponse = await vllm.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
if (content && request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
if (originalToolChoice) {
const forcedResult = checkForForcedToolUsage(
currentResponse,
originalToolChoice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = forcedResult.hasUsedForcedTool
usedForcedTools = forcedResult.usedForcedTools
}
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
if (request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'vllm' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
logger.info(
`Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_TOOL_ITERATIONS})`
)
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) return null
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await vllm.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
if (nextPayload.tool_choice && typeof nextPayload.tool_choice === 'object') {
const forcedResult = checkForForcedToolUsage(
currentResponse,
nextPayload.tool_choice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = forcedResult.hasUsedForcedTool
usedForcedTools = forcedResult.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
if (request.responseFormat) {
content = content.replace(/```json\n?|\n?```/g, '').trim()
}
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'vllm' }
)
}
if (request.stream) {
logger.info('Using streaming for final response after tool processing')
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
const streamingParams: ChatCompletionCreateParamsStreaming = {
...payload,
messages: currentMessages,
tool_choice: 'none',
stream: true,
stream_options: { include_usage: true },
}
const streamResponse = await vllm.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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()
}
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)
output.cost = {
input: accumulatedCost.input + streamCost.input,
output: accumulatedCost.output + streamCost.output,
toolCost: tc || undefined,
total: accumulatedCost.total + streamCost.total + tc,
}
}),
})
return streamingResult
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
let errorMessage = toError(error).message
let errorType: string | undefined
let errorCode: number | undefined
if (error && typeof error === 'object' && 'error' in error) {
const vllmError = error.error as any
if (vllmError && typeof vllmError === 'object') {
errorMessage = vllmError.message || errorMessage
errorType = vllmError.type
errorCode = vllmError.code
}
}
logger.error('Error in vLLM request:', {
error: errorMessage,
errorType,
errorCode,
duration: totalDuration,
})
throw new ProviderError(errorMessage, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+27
View File
@@ -0,0 +1,27 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a vLLM streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromVLLMStream(
vllmStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(vllmStream, 'vLLM', onComplete)
}
/**
* Checks if a forced tool was used in a vLLM response.
* Uses the shared OpenAI-compatible forced tool usage helper.
*/
export function checkForForcedToolUsage(
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } {
return checkForForcedToolUsageOpenAI(response, toolChoice, 'vLLM', forcedTools, usedForcedTools)
}
+605
View File
@@ -0,0 +1,605 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions'
import type { StreamingExecution } from '@/executor/types'
import { MAX_TOOL_ITERATIONS } from '@/providers'
import { formatMessagesForProvider } from '@/providers/attachments'
import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
import { createStreamingExecution } from '@/providers/streaming-execution'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
import type {
Message,
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
} from '@/providers/utils'
import {
checkForForcedToolUsage,
createReadableStreamFromXAIStream,
createResponseFormatPayload,
} from '@/providers/xai/utils'
import { executeTool } from '@/tools'
const logger = createLogger('XAIProvider')
export const xAIProvider: ProviderConfig = {
id: 'xai',
name: 'xAI',
description: "xAI's Grok models",
version: '1.0.0',
models: getProviderModels('xai'),
defaultModel: getProviderDefaultModel('xai'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for xAI')
}
const xai = new OpenAI({
apiKey: request.apiKey,
baseURL: 'https://api.x.ai/v1',
})
logger.info('XAI Provider - Initial request configuration:', {
hasTools: !!request.tools?.length,
toolCount: request.tools?.length || 0,
hasResponseFormat: !!request.responseFormat,
model: request.model,
streaming: !!request.stream,
})
const allMessages: Message[] = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'xai') as Message[]
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
if (tools?.length && request.responseFormat) {
logger.warn(
'XAI Provider - Detected both tools and response format. Using tools first, then response format for final response.'
)
}
const basePayload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) basePayload.temperature = request.temperature
if (request.maxTokens != null) basePayload.max_completion_tokens = request.maxTokens
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'xai')
}
if (request.stream && (!tools || tools.length === 0)) {
logger.info('XAI Provider - Using direct streaming (no tools)')
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
const streamingParams: ChatCompletionCreateParamsStreaming = request.responseFormat
? {
...createResponseFormatPayload(basePayload, allMessages, request.responseFormat),
stream: true,
stream_options: { include_usage: true },
}
: { ...basePayload, stream: true, stream_options: { include_usage: true } }
const streamResponse = await xai.chat.completions.create(
streamingParams,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const initialCallTime = Date.now()
// xAI cannot use tools and response_format together in the same request
const initialPayload = { ...basePayload }
let originalToolChoice: any
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
if (preparedTools?.tools?.length && preparedTools.toolChoice) {
const { tools: filteredTools, toolChoice } = preparedTools
initialPayload.tools = filteredTools
initialPayload.tool_choice = toolChoice
originalToolChoice = toolChoice
} else if (request.responseFormat) {
const responseFormatPayload = createResponseFormatPayload(
basePayload,
allMessages,
request.responseFormat
)
Object.assign(initialPayload, responseFormatPayload)
}
let currentResponse = await xai.chat.completions.create(
initialPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let hasUsedForcedTool = false
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
if (originalToolChoice) {
const result = checkForForcedToolUsage(
currentResponse,
originalToolChoice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'xai' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) {
logger.warn('XAI Provider - Tool not found:', { toolName })
return null
}
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('XAI Provider - Error processing tool call:', {
error: toError(error).message,
toolCall: toolCall.function.name,
})
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
logger.warn('XAI Provider - Tool execution failed:', {
toolName,
error: result.error,
})
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
let nextPayload: any
if (
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload = {
...basePayload,
messages: currentMessages,
tools: preparedTools?.tools,
tool_choice: {
type: 'function',
function: { name: remainingTools[0] },
},
}
} else {
if (request.responseFormat) {
nextPayload = createResponseFormatPayload(
basePayload,
allMessages,
request.responseFormat,
currentMessages
)
} else {
nextPayload = {
...basePayload,
messages: currentMessages,
tool_choice: 'auto',
tools: preparedTools?.tools,
}
}
}
} else {
if (request.responseFormat) {
nextPayload = createResponseFormatPayload(
basePayload,
allMessages,
request.responseFormat,
currentMessages
)
} else {
nextPayload = {
...basePayload,
messages: currentMessages,
tools: preparedTools?.tools,
tool_choice: 'auto',
}
}
}
const nextModelStartTime = Date.now()
currentResponse = await xai.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
if (nextPayload.tool_choice && typeof nextPayload.tool_choice === 'object') {
const result = checkForForcedToolUsage(
currentResponse,
nextPayload.tool_choice,
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'xai' }
)
}
} catch (error) {
logger.error('XAI Provider - Error in tool processing loop:', {
error: toError(error).message,
iterationCount,
})
}
if (request.stream) {
let finalStreamingPayload: any
if (request.responseFormat) {
finalStreamingPayload = {
...createResponseFormatPayload(
basePayload,
allMessages,
request.responseFormat,
currentMessages
),
stream: true,
}
} else {
finalStreamingPayload = {
...basePayload,
messages: currentMessages,
tool_choice: 'auto',
tools: preparedTools?.tools,
stream: true,
}
}
const streamResponse = await xai.chat.completions.create(
finalStreamingPayload as any,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.info('XAI Provider - Request completed:', {
totalDuration,
iterationCount: iterationCount + 1,
toolCallCount: toolCalls.length,
hasContent: !!content,
contentLength: content?.length || 0,
})
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('XAI Provider - Request failed:', {
error: toError(error).message,
duration: totalDuration,
hasTools: !!tools?.length,
hasResponseFormat: !!request.responseFormat,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
/**
* Enriches the last model segment with per-iteration content from a Chat
* Completions response: assistant text, tool calls, finish reason, token usage.
*/
+55
View File
@@ -0,0 +1,55 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from an xAI streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromXAIStream(
xaiStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(xaiStream, 'xAI', onComplete)
}
/**
* Creates a response format payload for xAI requests with JSON schema.
*/
export function createResponseFormatPayload(
basePayload: any,
allMessages: any[],
responseFormat: any,
currentMessages?: any[]
) {
const payload = {
...basePayload,
messages: currentMessages || allMessages,
}
if (responseFormat) {
payload.response_format = {
type: 'json_schema',
json_schema: {
name: responseFormat.name || 'structured_response',
schema: responseFormat.schema || responseFormat,
strict: responseFormat.strict !== false,
},
}
}
return payload
}
/**
* Checks if a forced tool was used in an xAI response.
* Uses the shared OpenAI-compatible forced tool usage helper.
*/
export function checkForForcedToolUsage(
response: any,
toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any },
forcedTools: string[],
usedForcedTools: string[]
): { hasUsedForcedTool: boolean; usedForcedTools: string[] } {
return checkForForcedToolUsageOpenAI(response, toolChoice, 'xAI', forcedTools, usedForcedTools)
}
+664
View File
@@ -0,0 +1,664 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import OpenAI from 'openai'
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 {
ProviderConfig,
ProviderRequest,
ProviderResponse,
TimeSegment,
} from '@/providers/types'
import { ProviderError } from '@/providers/types'
import {
calculateCost,
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
trackForcedToolUsage,
} from '@/providers/utils'
import { createReadableStreamFromZaiStream } from '@/providers/zai/utils'
import { executeTool } from '@/tools'
const logger = createLogger('ZaiProvider')
const ZAI_BASE_URL = 'https://api.z.ai/api/paas/v4'
function buildSchemaGuidance(responseFormat: ProviderRequest['responseFormat']): string {
if (!responseFormat) return ''
const schema = responseFormat.schema || responseFormat
return `\n\nYour response must be valid JSON matching this schema${
responseFormat.name ? ` ("${responseFormat.name}")` : ''
}:\n${JSON.stringify(schema, null, 2)}`
}
function withSchemaGuidance(messages: any[], guidance: string): any[] {
if (!guidance) return messages
if (messages[0]?.role === 'system') {
return [{ ...messages[0], content: `${messages[0].content}${guidance}` }, ...messages.slice(1)]
}
return [{ role: 'system', content: guidance.trimStart() }, ...messages]
}
/**
* Z.ai's GLM models via an OpenAI-compatible chat-completions API (`api.z.ai`), with these
* documented deviations from a standard OpenAI-compatible adapter:
* - Output length is capped via `max_tokens`, not OpenAI's `max_completion_tokens`.
* - `tool_choice` only supports `"auto"` — forcing a specific tool or disabling tool use via
* the parameter is rejected, so any forced/none choice is downgraded to `"auto"` (logged as
* a warning), and a "stop calling tools" pass drops `tools`/`tool_choice` entirely instead of
* sending an unsupported `"none"`.
* - `response_format` only supports `"text"`/`"json_object"`, not `"json_schema"` — the
* expected schema is also injected into the system prompt as best-effort guidance.
* - `thinking: { type }` and `reasoning_effort` map directly from `request.thinkingLevel` and
* `request.reasoningEffort`.
*/
export const zaiProvider: ProviderConfig = {
id: 'zai',
name: 'Z.ai',
description: "Z.ai's GLM models via an OpenAI-compatible API",
version: '1.0.0',
models: getProviderModels('zai'),
defaultModel: getProviderDefaultModel('zai'),
executeRequest: async (
request: ProviderRequest
): Promise<ProviderResponse | StreamingExecution> => {
if (!request.apiKey) {
throw new Error('API key is required for Z.ai')
}
const providerStartTime = Date.now()
const providerStartTimeISO = new Date(providerStartTime).toISOString()
try {
const zai = new OpenAI({
apiKey: request.apiKey,
baseURL: ZAI_BASE_URL,
})
const allMessages = []
if (request.systemPrompt) {
allMessages.push({
role: 'system',
content: request.systemPrompt,
})
}
if (request.context) {
allMessages.push({
role: 'user',
content: request.context,
})
}
if (request.messages) {
allMessages.push(...request.messages)
}
const formattedMessages = formatMessagesForProvider(allMessages, 'zai')
const tools = request.tools?.length
? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
: undefined
const payload: any = {
model: request.model,
messages: formattedMessages,
}
if (request.temperature !== undefined) payload.temperature = request.temperature
if (request.maxTokens != null) payload.max_tokens = request.maxTokens
if (request.thinkingLevel === 'enabled' || request.thinkingLevel === 'disabled') {
payload.thinking = { type: request.thinkingLevel }
}
if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') {
payload.reasoning_effort = request.reasoningEffort
}
const responseFormatPayload = request.responseFormat
? ({ type: 'json_object' as const } as const)
: undefined
let preparedTools: ReturnType<typeof prepareToolsWithUsageControl> | null = null
let hasActiveTools = false
if (tools?.length) {
preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai')
const { tools: filteredTools, toolChoice } = preparedTools
if (filteredTools?.length && toolChoice) {
payload.tools = filteredTools
payload.tool_choice = 'auto'
hasActiveTools = true
if (preparedTools.forcedTools.length > 0) {
logger.warn(
"Z.ai does not support forcing a specific tool via tool_choice (API only accepts 'auto') — ignoring force setting and falling back to auto",
{ forcedTools: preparedTools.forcedTools, model: request.model }
)
}
logger.info('Z.ai request configuration:', {
toolCount: filteredTools.length,
toolChoice: 'auto',
model: request.model,
})
}
}
const deferResponseFormat = !!responseFormatPayload && hasActiveTools
if (responseFormatPayload && !deferResponseFormat) {
payload.response_format = responseFormatPayload
payload.messages = withSchemaGuidance(
payload.messages,
buildSchemaGuidance(request.responseFormat)
)
}
if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
logger.info('Using streaming response for Z.ai request (no tools)')
const streamResponse = await zai.chat.completions.create(
{
...payload,
stream: true,
stream_options: { include_usage: true },
},
request.abortSignal ? { signal: request.abortSignal } : undefined
)
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 }) =>
createReadableStreamFromZaiStream(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
)
output.cost = {
input: costResult.input,
output: costResult.output,
total: costResult.total,
}
}),
})
return streamingResult
}
const initialCallTime = Date.now()
const originalToolChoice = payload.tool_choice
const forcedTools = preparedTools?.forcedTools || []
let usedForcedTools: string[] = []
let currentResponse = await zai.chat.completions.create(
payload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const firstResponseTime = Date.now() - initialCallTime
let content = currentResponse.choices[0]?.message?.content || ''
const tokens = {
input: currentResponse.usage?.prompt_tokens || 0,
output: currentResponse.usage?.completion_tokens || 0,
total: currentResponse.usage?.total_tokens || 0,
}
const toolCalls = []
const toolResults: Record<string, unknown>[] = []
const currentMessages = [...formattedMessages]
let iterationCount = 0
let hasUsedForcedTool = false
let modelTime = firstResponseTime
let toolsTime = 0
const timeSegments: TimeSegment[] = [
{
type: 'model',
name: request.model,
startTime: initialCallTime,
endTime: initialCallTime + firstResponseTime,
duration: firstResponseTime,
},
]
if (
typeof originalToolChoice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
originalToolChoice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
try {
while (iterationCount < MAX_TOOL_ITERATIONS) {
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
toolCallsInResponse,
{ model: request.model, provider: 'zai' }
)
if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
break
}
const toolsStartTime = Date.now()
const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
const toolCallStartTime = Date.now()
const toolName = toolCall.function.name
try {
const toolArgs = JSON.parse(toolCall.function.arguments)
const tool = request.tools?.find((t) => t.id === toolName)
if (!tool) {
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: `Tool "${toolName}" is not available`,
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
const result = await executeTool(toolName, executionParams, {
signal: request.abortSignal,
})
const toolCallEndTime = Date.now()
return {
toolCall,
toolName,
toolParams,
result,
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
} catch (error) {
const toolCallEndTime = Date.now()
logger.error('Error processing tool call:', { error, toolName })
return {
toolCall,
toolName,
toolParams: {},
result: {
success: false,
output: undefined,
error: getErrorMessage(error, 'Tool execution failed'),
},
startTime: toolCallStartTime,
endTime: toolCallEndTime,
duration: toolCallEndTime - toolCallStartTime,
}
}
})
const executionResults = await Promise.allSettled(toolExecutionPromises)
currentMessages.push({
role: 'assistant',
content: null,
tool_calls: toolCallsInResponse.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments,
},
})),
})
for (const settledResult of executionResults) {
if (settledResult.status === 'rejected' || !settledResult.value) continue
const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
settledResult.value
timeSegments.push({
type: 'tool',
name: toolName,
startTime: startTime,
endTime: endTime,
duration: duration,
toolCallId: toolCall.id,
})
let resultContent: any
if (result.success && result.output) {
toolResults.push(result.output)
resultContent = result.output
} else {
resultContent = {
error: true,
message: result.error || 'Tool execution failed',
tool: toolName,
}
}
toolCalls.push({
name: toolName,
arguments: toolParams,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString(),
duration: duration,
result: resultContent,
success: result.success,
})
currentMessages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(resultContent),
})
}
const thisToolsTime = Date.now() - toolsStartTime
toolsTime += thisToolsTime
const nextPayload = {
...payload,
messages: currentMessages,
}
if (
typeof originalToolChoice === 'object' &&
hasUsedForcedTool &&
forcedTools.length > 0
) {
const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
if (remainingTools.length > 0) {
nextPayload.tool_choice = {
type: 'function',
function: { name: remainingTools[0] },
}
logger.info(`Forcing next tool: ${remainingTools[0]}`)
} else {
nextPayload.tool_choice = 'auto'
logger.info('All forced tools have been used, switching to auto tool_choice')
}
}
const nextModelStartTime = Date.now()
currentResponse = await zai.chat.completions.create(
nextPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
if (
typeof nextPayload.tool_choice === 'object' &&
currentResponse.choices[0]?.message?.tool_calls
) {
const toolCallsResponse = currentResponse.choices[0].message.tool_calls
const result = trackForcedToolUsage(
toolCallsResponse,
nextPayload.tool_choice,
logger,
'openai',
forcedTools,
usedForcedTools
)
hasUsedForcedTool = result.hasUsedForcedTool
usedForcedTools = result.usedForcedTools
}
const nextModelEndTime = Date.now()
const thisModelTime = nextModelEndTime - nextModelStartTime
timeSegments.push({
type: 'model',
name: request.model,
startTime: nextModelStartTime,
endTime: nextModelEndTime,
duration: thisModelTime,
})
modelTime += thisModelTime
if (currentResponse.choices[0]?.message?.content) {
content = currentResponse.choices[0].message.content
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
iterationCount++
}
if (iterationCount === MAX_TOOL_ITERATIONS) {
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'zai' }
)
}
} catch (error) {
logger.error('Error in Z.ai request:', { error })
throw error
}
if (request.stream) {
logger.info('Using streaming for final Z.ai response after tool processing')
const streamingPayload: any = {
...payload,
messages: currentMessages,
stream: true,
stream_options: { include_usage: true },
}
streamingPayload.tools = undefined
streamingPayload.tool_choice = undefined
if (deferResponseFormat && responseFormatPayload) {
streamingPayload.response_format = responseFormatPayload
streamingPayload.messages = withSchemaGuidance(
streamingPayload.messages,
buildSchemaGuidance(request.responseFormat)
)
}
const streamResponse = await zai.chat.completions.create(
streamingPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
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 }) =>
createReadableStreamFromZaiStream(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
}
if (deferResponseFormat && responseFormatPayload) {
logger.info('Applying deferred response_format after tool processing')
const finalFormatStartTime = Date.now()
const finalPayload: any = {
...payload,
messages: withSchemaGuidance(
currentMessages,
buildSchemaGuidance(request.responseFormat)
),
response_format: responseFormatPayload,
}
finalPayload.tools = undefined
finalPayload.tool_choice = undefined
currentResponse = await zai.chat.completions.create(
finalPayload,
request.abortSignal ? { signal: request.abortSignal } : undefined
)
const finalFormatEndTime = Date.now()
timeSegments.push({
type: 'model',
name: request.model,
startTime: finalFormatStartTime,
endTime: finalFormatEndTime,
duration: finalFormatEndTime - finalFormatStartTime,
})
modelTime += finalFormatEndTime - finalFormatStartTime
const formattedContent = currentResponse.choices[0]?.message?.content
if (formattedContent) {
content = formattedContent
}
if (currentResponse.usage) {
tokens.input += currentResponse.usage.prompt_tokens || 0
tokens.output += currentResponse.usage.completion_tokens || 0
tokens.total += currentResponse.usage.total_tokens || 0
}
enrichLastModelSegmentFromChatCompletions(
timeSegments,
currentResponse,
currentResponse.choices[0]?.message?.tool_calls,
{ model: request.model, provider: 'zai' }
)
}
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
return {
content,
model: request.model,
tokens,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
toolResults: toolResults.length > 0 ? toolResults : undefined,
timing: {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
modelTime: modelTime,
toolsTime: toolsTime,
firstResponseTime: firstResponseTime,
iterations: iterationCount + 1,
timeSegments: timeSegments,
},
}
} catch (error) {
const providerEndTime = Date.now()
const providerEndTimeISO = new Date(providerEndTime).toISOString()
const totalDuration = providerEndTime - providerStartTime
logger.error('Error in Z.ai request:', {
error,
duration: totalDuration,
})
throw new ProviderError(toError(error).message, {
startTime: providerStartTimeISO,
endTime: providerEndTimeISO,
duration: totalDuration,
})
}
},
}
+14
View File
@@ -0,0 +1,14 @@
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
import type { CompletionUsage } from 'openai/resources/completions'
import { createOpenAICompatibleStream } from '@/providers/utils'
/**
* Creates a ReadableStream from a Z.ai streaming response.
* Uses the shared OpenAI-compatible streaming utility.
*/
export function createReadableStreamFromZaiStream(
zaiStream: AsyncIterable<ChatCompletionChunk>,
onComplete?: (content: string, usage: CompletionUsage) => void
): ReadableStream<Uint8Array> {
return createOpenAICompatibleStream(zaiStream, 'Z.ai', onComplete)
}