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
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:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -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',
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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: '' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
interface Model {
|
||||
name: string
|
||||
model: string
|
||||
modified_at: string
|
||||
size: number
|
||||
digest: string
|
||||
details: object
|
||||
}
|
||||
|
||||
export interface ModelsObject {
|
||||
models: Model[]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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',
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user