From efadbea4e6710a61404a718367856cf611890f88 Mon Sep 17 00:00:00 2001 From: rajitkhanna Date: Sat, 12 Sep 2026 15:50:27 -0700 Subject: [PATCH 1/2] feat(providers): add Prism BYOK support --- apps/docs/components/icons.tsx | 16 + apps/docs/content/docs/platform/costs.mdx | 1 + .../content/docs/workflows/blocks/agent.mdx | 3 +- .../settings/components/byok/byok.tsx | 9 + apps/sim/components/icons.tsx | 16 + .../components/usage-consumers.tsx | 2 + apps/sim/lib/api-key/byok.test.ts | 49 +++ apps/sim/lib/api-key/byok.ts | 14 + apps/sim/lib/api/contracts/byok-keys.ts | 1 + apps/sim/providers/attachments.test.ts | 4 +- apps/sim/providers/attachments.ts | 5 + apps/sim/providers/models.test.ts | 44 +++ apps/sim/providers/models.ts | 59 ++++ .../openai-compat/streaming-tool-loop.ts | 1 + apps/sim/providers/prism/index.test.ts | 236 +++++++++++++ apps/sim/providers/prism/index.ts | 333 ++++++++++++++++++ apps/sim/providers/prism/utils.ts | 16 + apps/sim/providers/registry.ts | 2 + .../providers/streaming-tool-loop-shared.ts | 2 + apps/sim/providers/types.ts | 1 + apps/sim/providers/utils.ts | 1 + apps/sim/tools/types.ts | 1 + 22 files changed, 813 insertions(+), 3 deletions(-) create mode 100644 apps/sim/providers/prism/index.test.ts create mode 100644 apps/sim/providers/prism/index.ts create mode 100644 apps/sim/providers/prism/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 867a81af5c2..c99bd276132 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5392,6 +5392,22 @@ export function BasetenIcon(props: SVGProps) { ) } +export function PrismIcon(props: SVGProps) { + return ( + + Prism + + + ) +} + export function CohereIcon(props: SVGProps) { return ( diff --git a/apps/docs/content/docs/platform/costs.mdx b/apps/docs/content/docs/platform/costs.mdx index bbe592bad56..ab38047fb3b 100644 --- a/apps/docs/content/docs/platform/costs.mdx +++ b/apps/docs/content/docs/platform/costs.mdx @@ -179,6 +179,7 @@ The BYOK settings page groups providers the same way. | Fireworks | LLM calls | | Together AI | LLM calls | | Baseten | LLM calls | + | Prism | Open-source model inference (BYOK only) | | Ollama Cloud | LLM calls | | Fal.ai | Image and video generation | diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index 0ef2b325ac5..d372974181e 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -104,10 +104,11 @@ While an agent runs, Sim can stream its thinking and tool lifecycle live — the {/* agent-stream-capabilities:begin — generated by `bun run agent-stream-docs:generate`; do not edit between markers */} -Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Vertex AI, DeepSeek, Groq, AWS Bedrock** models. Other providers run tools without live chips and project the settled final answer when the run completes; they do not ask the model to regenerate that answer just to create a stream. +Live tool-call chips stream for **Prism, OpenAI, Anthropic, Azure Anthropic, Google, Vertex AI, DeepSeek, Groq, AWS Bedrock** models. Other providers run tools without live chips and project the settled final answer when the run completes; they do not ask the model to regenerate that answer just to create a stream. | Provider | Streamed thinking | Models | |----------|-------------------|--------| +| Prism | Full thinking deltas | `prism/deepseek-v4.1-flash`, `prism/deepseek-v4-flash` | | OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `gpt-6-astra`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4-pro`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.2-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5-pro`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o4-mini`, `o3`, `o3-mini`, `o1` | | Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-4-5` | | Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index b7b3e19d43f..3791e0ed522 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -35,6 +35,7 @@ import { ParallelIcon, PeopleDataLabsIcon, PerplexityIcon, + PrismIcon, ProspeoIcon, SerperIcon, TinyFishIcon, @@ -151,6 +152,13 @@ const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ description: 'LLM calls', placeholder: 'Enter your Baseten API key', }, + { + id: 'prism', + name: 'Prism', + icon: PrismIcon, + description: 'Open-source model inference', + placeholder: 'prism_sk_...', + }, { id: 'ollama-cloud', name: 'Ollama Cloud', @@ -355,6 +363,7 @@ const PROVIDER_SECTIONS: BYOKProviderSection[] = [ 'fireworks', 'together', 'baseten', + 'prism', 'ollama-cloud', 'falai', ], diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 867a81af5c2..c99bd276132 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5392,6 +5392,22 @@ export function BasetenIcon(props: SVGProps) { ) } +export function PrismIcon(props: SVGProps) { + return ( + + Prism + + + ) +} + export function CohereIcon(props: SVGProps) { return ( diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx index 6039688d960..dc018ac80d6 100644 --- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -22,6 +22,7 @@ import { OllamaIcon, OpenAIIcon, OpenRouterIcon, + PrismIcon, SakanaIcon, TogetherIcon, VertexIcon, @@ -73,6 +74,7 @@ const PROVIDER_ICONS: Readonly { expect(result).toEqual({ apiKey: 'platform-fireworks-key', isBYOK: false }) }) }) + +describe('getApiKeyWithBYOK for Prism', () => { + const model = 'prism/deepseek-v4-flash' + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockDecryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: encrypted.replace('encrypted-', 'decrypted-'), + })) + mockIsOrganizationBYOKEntitled.mockResolvedValue(true) + mockEnv.PRISM_API_KEY = 'must-not-be-used' + ;(useProvidersStore.getState as ReturnType).mockReturnValue({ + providers: { + ollama: { models: [] }, + vllm: { models: [] }, + litellm: { models: [] }, + }, + }) + }) + + it('prefers a stored BYOK key over an inline key', async () => { + dbChainMockFns.orderBy.mockResolvedValue([storedKey('prism-key')]) + + await expect( + getApiKeyWithBYOK('prism', model, uniqueWorkspaceId(), 'inline-key') + ).resolves.toEqual({ + apiKey: 'decrypted-prism-key', + isBYOK: true, + scope: 'workspace', + }) + }) + + it('uses an inline key only when no stored key exists', async () => { + await expect( + getApiKeyWithBYOK('prism', model, uniqueWorkspaceId(), 'inline-key') + ).resolves.toEqual({ + apiKey: 'inline-key', + isBYOK: false, + }) + }) + + it('never falls back to an environment or platform key', async () => { + await expect(getApiKeyWithBYOK('prism', model, uniqueWorkspaceId())).rejects.toThrow( + 'API key is required for Prism' + ) + expect(mockGetRotatingApiKey).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 1bacd8af607..321fffc1b34 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -211,6 +211,20 @@ export async function getApiKeyWithBYOK( return { apiKey: userProvidedKey || env.LITELLM_API_KEY || 'empty', isBYOK: false } } + if (provider === 'prism') { + if (workspaceId) { + const byokResult = await getBYOKKey(workspaceId, 'prism') + if (byokResult) { + logger.info('Using BYOK key for Prism', { model, workspaceId, scope: byokResult.scope }) + return byokResult + } + } + if (userProvidedKey) { + return { apiKey: userProvidedKey, isBYOK: false } + } + throw new Error(`API key is required for Prism ${model}`) + } + const isFireworksModel = provider === 'fireworks' || useProvidersStore.getState().providers.fireworks.models.includes(model) diff --git a/apps/sim/lib/api/contracts/byok-keys.ts b/apps/sim/lib/api/contracts/byok-keys.ts index d6ee1be20d7..121a7700451 100644 --- a/apps/sim/lib/api/contracts/byok-keys.ts +++ b/apps/sim/lib/api/contracts/byok-keys.ts @@ -13,6 +13,7 @@ export const byokProviderIdSchema = z.enum([ 'fireworks', 'together', 'baseten', + 'prism', 'ollama-cloud', 'falai', 'firecrawl', diff --git a/apps/sim/providers/attachments.test.ts b/apps/sim/providers/attachments.test.ts index f4775f71314..a32a6197dde 100644 --- a/apps/sim/providers/attachments.test.ts +++ b/apps/sim/providers/attachments.test.ts @@ -355,11 +355,11 @@ describe('provider attachments', () => { ).toThrow('Supported attachments: images') }) - it('rejects providers without file attachment support', () => { + it.each(['deepseek', 'prism'])('rejects file attachments for %s', (provider) => { expect(() => formatMessagesForProvider( [{ role: 'user', content: 'Analyze this file', files: [imageFile] }], - 'deepseek' + provider ) ).toThrow('not supported') }) diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index da92f1d8e57..b6a1153ccad 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -42,6 +42,7 @@ export type AttachmentProvider = | 'meta' | 'zai' | 'kimi' + | 'prism' export interface PreparedProviderAttachment { file: UserFile @@ -156,6 +157,7 @@ const UNSUPPORTED_FILE_PROVIDERS = new Set([ 'nvidia', 'meta', 'zai', + 'prism', ]) const PROVIDER_SUPPORTED_LABELS: Record = { @@ -180,6 +182,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = { meta: 'no file attachments in the current API adapter', zai: 'no file attachments in the current API adapter', kimi: 'images through image_url message parts on multimodal models', + prism: 'no file attachments in the current API adapter', } export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null { @@ -204,6 +207,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'meta') return 'meta' if (providerId === 'zai') return 'zai' if (providerId === 'kimi') return 'kimi' + if (providerId === 'prism') return 'prism' return null } @@ -419,6 +423,7 @@ function isMimeTypeSupportedByProvider( case 'nvidia': case 'meta': case 'zai': + case 'prism': return false default: { const _exhaustive: never = provider diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index d6fa3568d05..09422015782 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -513,6 +513,50 @@ describe('fireworks static catalog (the sim-auto pool)', () => { }) }) +describe('Prism provider definition', () => { + const modelIds = ['prism/deepseek-v4.1-flash', 'prism/deepseek-v4-flash'] + + it('registers its canonical models newest-first with the approved capabilities and pricing', () => { + expect(PROVIDER_DEFINITIONS.prism).toMatchObject({ + id: 'prism', + defaultModel: modelIds[0], + models: [ + { + id: modelIds[0], + pricing: { input: 0.3, cachedInput: 0.07, output: 1.2 }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + nativeStructuredOutputs: true, + maxOutputTokens: 384000, + reasoningEffort: { values: ['none', 'low', 'medium', 'high'] }, + }, + contextWindow: 1000000, + releaseDate: '2026-09-10', + }, + { + id: modelIds[1], + pricing: { input: 0.14, cachedInput: 0.07, output: 0.28 }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + nativeStructuredOutputs: true, + maxOutputTokens: 384000, + reasoningEffort: { values: ['none', 'low', 'medium', 'high'] }, + }, + contextWindow: 1000000, + releaseDate: '2026-09-09', + }, + ], + }) + expect(getProviderModels('prism')).toEqual(modelIds) + }) + + it('stays outside the hosted model set', () => { + for (const modelId of modelIds) expect(getHostedModels()).not.toContain(modelId) + }) +}) + describe('isModelDeprecated', () => { it('returns true for a catalogued deprecated model (case-insensitive)', () => { const id = firstDeprecatedModelId() diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 37ccde6c448..156356d92ba 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -26,6 +26,7 @@ import { OllamaIcon, OpenAIIcon, OpenRouterIcon, + PrismIcon, SakanaIcon, TogetherIcon, VertexIcon, @@ -257,6 +258,64 @@ export const PROVIDER_DEFINITIONS: Record = { contextInformationAvailable: false, models: [], }, + prism: { + id: 'prism', + name: 'Prism', + description: 'Prism provides open-source model inference', + defaultModel: 'prism/deepseek-v4.1-flash', + modelPatterns: [/^prism\//], + icon: PrismIcon, + color: '#1F1D1A', + isReseller: true, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + nativeStructuredOutputs: true, + }, + contextInformationAvailable: false, + models: [ + { + id: 'prism/deepseek-v4.1-flash', + pricing: { + input: 0.3, + cachedInput: 0.07, + output: 1.2, + updatedAt: '2026-09-12', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + nativeStructuredOutputs: true, + maxOutputTokens: 384000, + reasoningEffort: { + values: ['none', 'low', 'medium', 'high'], + }, + }, + contextWindow: 1000000, + releaseDate: '2026-09-10', + }, + { + id: 'prism/deepseek-v4-flash', + pricing: { + input: 0.14, + cachedInput: 0.07, + output: 0.28, + updatedAt: '2026-09-12', + }, + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + nativeStructuredOutputs: true, + maxOutputTokens: 384000, + reasoningEffort: { + values: ['none', 'low', 'medium', 'high'], + }, + }, + contextWindow: 1000000, + releaseDate: '2026-09-09', + }, + ], + }, openrouter: { id: 'openrouter', fileAttachment: { maxBytes: 50 * 1024 * 1024, strategy: 'remote-url' }, diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts index 674219f6ce5..2a66d23de3f 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -134,6 +134,7 @@ export function createOpenAICompatStreamingToolLoopStream( }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, modelTime, toolsTime, firstResponseTime, diff --git a/apps/sim/providers/prism/index.test.ts b/apps/sim/providers/prism/index.test.ts new file mode 100644 index 00000000000..eb784340c08 --- /dev/null +++ b/apps/sim/providers/prism/index.test.ts @@ -0,0 +1,236 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockCalculateCost, mockClientOptions, mockCreate, mockCreateToolStream, mockPrepareTools } = + vi.hoisted(() => ({ + mockCalculateCost: vi.fn(() => ({ + input: 0.000003, + output: 0.000006, + total: 0.000009, + pricing: { + input: 0.3, + cachedInput: 0.07, + output: 1.2, + updatedAt: '2026-09-12', + }, + })), + mockClientOptions: vi.fn(), + mockCreate: vi.fn(), + mockCreateToolStream: vi.fn(), + mockPrepareTools: vi.fn(), + })) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + + constructor(options: unknown) { + mockClientOptions(options) + } + } + ), +})) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => ['prism/deepseek-v4.1-flash', 'prism/deepseek-v4-flash']), + getProviderDefaultModel: vi.fn(() => 'prism/deepseek-v4.1-flash'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ + createOpenAICompatStreamingToolLoopStream: mockCreateToolStream, +})) + +vi.mock('@/providers/transport', () => ({ + openAICompatTransport: vi.fn(() => ({})), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: mockCalculateCost, + enforceStrictSchema: vi.fn((schema: Record) => ({ + ...schema, + required: ['answer'], + additionalProperties: false, + })), + prepareToolsWithUsageControl: mockPrepareTools, +})) + +import { PRISM_BASE_URL, prismProvider } from '@/providers/prism' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'prism/deepseek-v4.1-flash', + apiKey: 'prism_sk_test', + systemPrompt: 'Be concise.', + messages: [{ role: 'user', content: 'Hello' }], + temperature: 0.4, + maxTokens: 321, + reasoningEffort: 'high', + responseFormat: { + name: 'answer', + schema: { type: 'object', properties: { answer: { type: 'string' } } }, + }, + ...overrides, + } +} + +describe('prismProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockResolvedValue({ + choices: [{ message: { content: '{"answer":"Hi"}' } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }) + }) + + it('uses the fixed endpoint and sends the canonical model ID unchanged', async () => { + await prismProvider.executeRequest(request()) + + expect(mockClientOptions).toHaveBeenCalledWith({ + apiKey: 'prism_sk_test', + baseURL: PRISM_BASE_URL, + }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: 'prism/deepseek-v4.1-flash', + messages: [ + { role: 'system', content: 'Be concise.' }, + { role: 'user', content: 'Hello' }, + ], + temperature: 0.4, + max_tokens: 321, + reasoning_effort: 'high', + response_format: { + type: 'json_schema', + json_schema: { + name: 'answer', + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + additionalProperties: false, + }, + strict: true, + }, + }, + }, + undefined + ) + }) + + it('uses the shared tool loop with reasoning replay and returns tool results', async () => { + mockPrepareTools.mockReturnValue({ + tools: [ + { + type: 'function', + function: { + name: 'lookup', + description: 'Look up a value', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + }) + mockCreateToolStream.mockImplementation( + (options: { onComplete: (result: Record) => void }) => { + options.onComplete({ + content: 'Found it', + tokens: { input: 10, output: 5, total: 15 }, + cost: { input: 0, output: 0, toolCost: 0.25, total: 0.25 }, + toolCalls: { list: [], count: 0 }, + toolResults: [{ value: 'result' }], + modelTime: 1, + toolsTime: 1, + firstResponseTime: 1, + iterations: 2, + }) + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) + } + ) + + const result = await prismProvider.executeRequest( + request({ + responseFormat: undefined, + tools: [ + { + id: 'lookup', + description: 'Look up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + ) + + expect(mockCreateToolStream).toHaveBeenCalledWith( + expect.objectContaining({ + providerName: 'Prism', + preserveAssistantReasoning: true, + basePayload: expect.objectContaining({ + model: 'prism/deepseek-v4.1-flash', + tool_choice: 'auto', + }), + }) + ) + expect(result).toMatchObject({ toolResults: [{ value: 'result' }] }) + expect(mockCalculateCost).toHaveBeenCalledWith('prism/deepseek-v4.1-flash', 10, 5) + expect(result.cost).toEqual({ + input: 0.000003, + output: 0.000006, + toolCost: 0.25, + total: 0.250009, + pricing: { + input: 0.3, + cachedInput: 0.07, + output: 1.2, + updatedAt: '2026-09-12', + }, + }) + }) + + it.each(['none', 'low', 'medium', 'high'])('sends Prism reasoning effort %s', async (value) => { + await prismProvider.executeRequest(request({ reasoningEffort: value })) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ reasoning_effort: value }), + undefined + ) + }) + + it.each([undefined, 'auto'])('omits Prism reasoning effort %s', async (value) => { + await prismProvider.executeRequest(request({ reasoningEffort: value })) + expect(mockCreate).toHaveBeenCalledWith( + expect.not.objectContaining({ reasoning_effort: expect.anything() }), + undefined + ) + }) + + it('rejects reasoning effort values unsupported by Prism', async () => { + await expect( + prismProvider.executeRequest(request({ reasoningEffort: 'xhigh' })) + ).rejects.toThrow('Unsupported Prism reasoning effort: xhigh') + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('requires a Prism API key', async () => { + await expect(prismProvider.executeRequest(request({ apiKey: undefined }))).rejects.toThrow( + 'API key is required for Prism' + ) + }) +}) diff --git a/apps/sim/providers/prism/index.ts b/apps/sim/providers/prism/index.ts new file mode 100644 index 00000000000..47a76d8d148 --- /dev/null +++ b/apps/sim/providers/prism/index.ts @@ -0,0 +1,333 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import OpenAI from 'openai' +import type { + ChatCompletionCreateParamsNonStreaming, + ChatCompletionCreateParamsStreaming, + ChatCompletionMessageParam, +} from 'openai/resources/chat/completions' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import { formatMessagesForProvider } from '@/providers/attachments' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' +import { createReadableStreamFromPrismStream } from '@/providers/prism/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { createStreamingExecution } from '@/providers/streaming-execution' +import type { StreamingToolLoopComplete } from '@/providers/streaming-tool-loop-shared' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import { openAICompatTransport } from '@/providers/transport' +import type { + FunctionCallResponse, + Message, + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + enforceStrictSchema, + isFunctionToolCall, + prepareToolsWithUsageControl, +} from '@/providers/utils' + +const logger = createLogger('PrismProvider') + +export const PRISM_BASE_URL = 'https://api.prisminference.com/v1' + +type PrismReasoningEffort = 'none' | 'low' | 'medium' | 'high' + +type PrismPayload = ChatCompletionCreateParamsNonStreaming & { + reasoning_effort?: PrismReasoningEffort +} + +function buildResponseFormat(request: ProviderRequest): PrismPayload['response_format'] { + if (!request.responseFormat) return undefined + const isStrict = request.responseFormat.strict !== false + return { + type: 'json_schema', + json_schema: { + name: request.responseFormat.name || 'response_schema', + schema: isStrict + ? enforceStrictSchema(request.responseFormat.schema) + : request.responseFormat.schema, + strict: isStrict, + }, + } +} + +function getReasoningEffort(value: string | undefined): PrismReasoningEffort | undefined { + if (!value || value === 'auto') return undefined + if (value === 'none' || value === 'low' || value === 'medium' || value === 'high') return value + throw new Error(`Unsupported Prism reasoning effort: ${value}`) +} + +async function drainStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + while (!(await reader.read()).done) {} +} + +export const prismProvider: ProviderConfig = { + id: 'prism', + name: 'Prism', + description: 'Prism provides open-source model inference', + version: '1.0.0', + models: getProviderModels('prism'), + defaultModel: getProviderDefaultModel('prism'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) { + throw new Error('API key is required for Prism') + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + const prism = new OpenAI({ + ...openAICompatTransport(), + apiKey: request.apiKey, + baseURL: PRISM_BASE_URL, + }) + 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, 'prism') + // double-cast-allowed: the attachment formatter returns provider-neutral messages that match OpenAI chat message params for this text-only adapter + const messages = formattedMessages as unknown as ChatCompletionMessageParam[] + const payload: PrismPayload = { + model: request.model, + messages, + } + if (request.temperature !== undefined) payload.temperature = request.temperature + if (request.maxTokens != null) payload.max_tokens = request.maxTokens + const reasoningEffort = getReasoningEffort(request.reasoningEffort) + if (reasoningEffort) payload.reasoning_effort = reasoningEffort + const responseFormat = buildResponseFormat(request) + if (responseFormat) payload.response_format = responseFormat + + const tools = request.tools?.length + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) + : undefined + const preparedTools = tools?.length + ? prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') + : null + if (preparedTools?.tools?.length) { + payload.tools = preparedTools.tools + const toolChoice = preparedTools.toolChoice + if ( + toolChoice === 'auto' || + toolChoice === 'none' || + (typeof toolChoice === 'object' && toolChoice.type === 'function') + ) { + payload.tool_choice = toolChoice + } + } + + if (payload.tools?.length) { + const timeSegments: TimeSegment[] = [] + const completed: { value?: StreamingToolLoopComplete } = {} + const createToolStream = () => + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Prism', + request, + basePayload: { ...payload }, + messages, + createStream: (params, options) => + prism.chat.completions.create( + { + ...params, + stream: true, + stream_options: { include_usage: true }, + }, + options + ), + logger, + timeSegments, + forcedTools: preparedTools?.forcedTools, + preserveAssistantReasoning: true, + onComplete: (result) => { + completed.value = result + }, + }) + + if (request.stream) { + return createStreamingExecution({ + model: request.model, + 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 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + const stream = createToolStream() + const updateOutput = () => { + const result = completed.value + if (!result) return + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + } + return stream.pipeThrough( + new TransformStream({ + transform(event, controller) { + controller.enqueue(event) + }, + flush: updateOutput, + }) + ) + }, + }) + } + + await drainStream(createToolStream()) + const result = completed.value + if (!result) throw new Error('Prism tool stream ended without a result') + const modelCost = calculateCost(request.model, result.tokens.input, result.tokens.output) + const toolCost = result.cost?.toolCost + return { + content: result.content, + model: request.model, + tokens: result.tokens, + cost: { + ...modelCost, + ...(toolCost !== undefined ? { toolCost } : {}), + total: modelCost.total + (toolCost ?? 0), + }, + toolCalls: result.toolCalls?.list as FunctionCallResponse[] | undefined, + toolResults: result.toolResults, + timing: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + modelTime: result.modelTime, + toolsTime: result.toolsTime, + firstResponseTime: result.firstResponseTime, + iterations: result.iterations, + timeSegments, + }, + } + } + + if (request.stream) { + const streamResponse = await prism.chat.completions.create( + { + ...payload, + stream: true, + stream_options: { include_usage: true }, + } as ChatCompletionCreateParamsStreaming, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + return 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, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createReadableStreamFromPrismStream(streamResponse, (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + output.cost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) segment.thinkingContent = thinking + } + finalizeTiming() + }), + }) + } + + const responseStartTime = Date.now() + const response = await prism.chat.completions.create( + payload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const responseEndTime = Date.now() + const usage = response.usage + const tokens = { + input: usage?.prompt_tokens ?? 0, + output: usage?.completion_tokens ?? 0, + total: usage?.total_tokens ?? 0, + } + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: responseStartTime, + endTime: responseEndTime, + duration: responseEndTime - responseStartTime, + }, + ] + enrichLastModelSegmentFromChatCompletions( + timeSegments, + response, + response.choices[0]?.message?.tool_calls?.filter(isFunctionToolCall), + { + model: request.model, + provider: 'prism', + } + ) + return { + content: response.choices[0]?.message?.content ?? '', + model: request.model, + tokens, + cost: calculateCost(request.model, tokens.input, tokens.output), + timing: { + startTime: providerStartTimeISO, + endTime: new Date(responseEndTime).toISOString(), + duration: responseEndTime - providerStartTime, + modelTime: responseEndTime - responseStartTime, + firstResponseTime: responseEndTime - responseStartTime, + iterations: 1, + timeSegments, + }, + } + } catch (error) { + const endTime = Date.now() + throw new ProviderError( + `Prism API error: ${toError(error).message}`, + { + startTime: providerStartTimeISO, + endTime: new Date(endTime).toISOString(), + duration: endTime - providerStartTime, + }, + { cause: error } + ) + } + }, +} diff --git a/apps/sim/providers/prism/utils.ts b/apps/sim/providers/prism/utils.ts new file mode 100644 index 00000000000..a17b6858645 --- /dev/null +++ b/apps/sim/providers/prism/utils.ts @@ -0,0 +1,16 @@ +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' + +export function createReadableStreamFromPrismStream( + prismStream: AsyncIterable, + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(prismStream, { + providerName: 'Prism', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) +} diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index a1d70caa046..c7b25296e1c 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -18,6 +18,7 @@ import { ollamaProvider } from '@/providers/ollama' import { ollamaCloudProvider } from '@/providers/ollama-cloud' import { openaiProvider } from '@/providers/openai' import { openRouterProvider } from '@/providers/openrouter' +import { prismProvider } from '@/providers/prism' import { sakanaProvider } from '@/providers/sakana' import { togetherProvider } from '@/providers/together' import type { ProviderConfig, ProviderId } from '@/providers/types' @@ -51,6 +52,7 @@ const providerRegistry: Record = { fireworks: fireworksProvider, together: togetherProvider, baseten: basetenProvider, + prism: prismProvider, ollama: ollamaProvider, 'ollama-cloud': ollamaCloudProvider, bedrock: bedrockProvider, diff --git a/apps/sim/providers/streaming-tool-loop-shared.ts b/apps/sim/providers/streaming-tool-loop-shared.ts index 5ac0c49d777..3d893a24c65 100644 --- a/apps/sim/providers/streaming-tool-loop-shared.ts +++ b/apps/sim/providers/streaming-tool-loop-shared.ts @@ -22,6 +22,7 @@ export const STREAMING_TOOL_CALL_PROVIDERS: ReadonlySet = new Set([ 'azure-anthropic', 'groq', 'deepseek', + 'prism', 'google', 'vertex', 'bedrock', @@ -33,6 +34,7 @@ export interface StreamingToolLoopComplete { tokens: { input: number; output: number; total: number } cost: NormalizedBlockOutput['cost'] toolCalls?: { list: unknown[]; count: number } + toolResults?: Record[] modelTime: number toolsTime: number firstResponseTime: number diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index d8793926360..69d6f9546b9 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -25,6 +25,7 @@ export type ProviderId = | 'fireworks' | 'together' | 'baseten' + | 'prism' | 'vllm' | 'litellm' | 'bedrock' diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ba9d19a2728..b4bbfccbc01 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -170,6 +170,7 @@ export const providers: Record = { fireworks: buildProviderMetadata('fireworks'), together: buildProviderMetadata('together'), baseten: buildProviderMetadata('baseten'), + prism: buildProviderMetadata('prism'), } export function updateOllamaProviderModels(models: string[]): void { diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index ef4a63d92a5..8ee38740e7e 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -17,6 +17,7 @@ export type BYOKProviderId = | 'fireworks' | 'together' | 'baseten' + | 'prism' | 'ollama-cloud' | 'falai' | 'firecrawl' From 13c672159df7de7a0dbe2faf45009e7ac027a683 Mon Sep 17 00:00:00 2001 From: rajitkhanna Date: Sat, 12 Sep 2026 16:01:23 -0700 Subject: [PATCH 2/2] fix(providers): retain Prism streaming tool results --- apps/sim/providers/prism/index.test.ts | 151 ++++++++++++++----------- apps/sim/providers/prism/index.ts | 40 ++++--- apps/sim/providers/prism/utils.ts | 16 --- 3 files changed, 105 insertions(+), 102 deletions(-) delete mode 100644 apps/sim/providers/prism/utils.ts diff --git a/apps/sim/providers/prism/index.test.ts b/apps/sim/providers/prism/index.test.ts index eb784340c08..ba63f2c850c 100644 --- a/apps/sim/providers/prism/index.test.ts +++ b/apps/sim/providers/prism/index.test.ts @@ -129,81 +129,96 @@ describe('prismProvider', () => { ) }) - it('uses the shared tool loop with reasoning replay and returns tool results', async () => { - mockPrepareTools.mockReturnValue({ - tools: [ - { - type: 'function', - function: { - name: 'lookup', - description: 'Look up a value', - parameters: { type: 'object', properties: {}, required: [] }, - }, - }, - ], - toolChoice: 'auto', - forcedTools: [], - }) - mockCreateToolStream.mockImplementation( - (options: { onComplete: (result: Record) => void }) => { - options.onComplete({ - content: 'Found it', - tokens: { input: 10, output: 5, total: 15 }, - cost: { input: 0, output: 0, toolCost: 0.25, total: 0.25 }, - toolCalls: { list: [], count: 0 }, - toolResults: [{ value: 'result' }], - modelTime: 1, - toolsTime: 1, - firstResponseTime: 1, - iterations: 2, - }) - return new ReadableStream({ - start(controller) { - controller.close() - }, - }) - } - ) - - const result = await prismProvider.executeRequest( - request({ - responseFormat: undefined, + it.each([ + { mode: 'non-streaming', stream: false }, + { mode: 'streaming', stream: true }, + ])( + 'uses the shared tool loop with reasoning replay and returns tool results ($mode)', + async ({ stream }) => { + mockPrepareTools.mockReturnValue({ tools: [ { - id: 'lookup', - description: 'Look up a value', - params: {}, - parameters: { type: 'object', properties: {}, required: [] }, + type: 'function', + function: { + name: 'lookup', + description: 'Look up a value', + parameters: { type: 'object', properties: {}, required: [] }, + }, }, ], + toolChoice: 'auto', + forcedTools: [], }) - ) + mockCreateToolStream.mockImplementation( + (options: { onComplete: (result: Record) => void }) => { + options.onComplete({ + content: 'Found it', + tokens: { input: 10, output: 5, total: 15 }, + cost: { input: 0, output: 0, toolCost: 0.25, total: 0.25 }, + toolCalls: { list: [], count: 0 }, + toolResults: [{ value: 'result' }], + modelTime: 1, + toolsTime: 1, + firstResponseTime: 1, + iterations: 2, + }) + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) + } + ) + + const result = await prismProvider.executeRequest( + request({ + responseFormat: undefined, + stream, + tools: [ + { + id: 'lookup', + description: 'Look up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + ) + + expect(mockCreateToolStream).toHaveBeenCalledWith( + expect.objectContaining({ + providerName: 'Prism', + preserveAssistantReasoning: true, + basePayload: expect.objectContaining({ + model: 'prism/deepseek-v4.1-flash', + tool_choice: 'auto', + }), + }) + ) - expect(mockCreateToolStream).toHaveBeenCalledWith( - expect.objectContaining({ - providerName: 'Prism', - preserveAssistantReasoning: true, - basePayload: expect.objectContaining({ - model: 'prism/deepseek-v4.1-flash', - tool_choice: 'auto', - }), + if ('stream' in result) { + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + expect(result.execution.output.toolResults).toEqual([{ value: 'result' }]) + return + } + + expect(result).toMatchObject({ toolResults: [{ value: 'result' }] }) + expect(mockCalculateCost).toHaveBeenCalledWith('prism/deepseek-v4.1-flash', 10, 5) + expect(result.cost).toEqual({ + input: 0.000003, + output: 0.000006, + toolCost: 0.25, + total: 0.250009, + pricing: { + input: 0.3, + cachedInput: 0.07, + output: 1.2, + updatedAt: '2026-09-12', + }, }) - ) - expect(result).toMatchObject({ toolResults: [{ value: 'result' }] }) - expect(mockCalculateCost).toHaveBeenCalledWith('prism/deepseek-v4.1-flash', 10, 5) - expect(result.cost).toEqual({ - input: 0.000003, - output: 0.000006, - toolCost: 0.25, - total: 0.250009, - pricing: { - input: 0.3, - cachedInput: 0.07, - output: 1.2, - updatedAt: '2026-09-12', - }, - }) - }) + } + ) it.each(['none', 'low', 'medium', 'high'])('sends Prism reasoning effort %s', async (value) => { await prismProvider.executeRequest(request({ reasoningEffort: value })) diff --git a/apps/sim/providers/prism/index.ts b/apps/sim/providers/prism/index.ts index 47a76d8d148..b96145b07c3 100644 --- a/apps/sim/providers/prism/index.ts +++ b/apps/sim/providers/prism/index.ts @@ -9,8 +9,8 @@ import type { import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' -import { createReadableStreamFromPrismStream } from '@/providers/prism/utils' import type { AgentStreamEvent } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' import type { StreamingToolLoopComplete } from '@/providers/streaming-tool-loop-shared' @@ -183,6 +183,7 @@ export const prismProvider: ProviderConfig = { output.tokens = result.tokens output.cost = result.cost output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + output.toolResults = result.toolResults if (output.providerTiming) { output.providerTiming.modelTime = result.modelTime output.providerTiming.toolsTime = result.toolsTime @@ -251,23 +252,26 @@ export const prismProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromPrismStream(streamResponse, (content, usage, thinking) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } - output.cost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - if (thinking) { - const segment = output.providerTiming?.timeSegments?.[0] - if (segment) segment.thinkingContent = thinking - } - finalizeTiming() + createOpenAICompatibleAgentEventStream(streamResponse, { + providerName: 'Prism', + onComplete: (result) => { + output.content = result.content + output.tokens = { + input: result.usage.prompt_tokens, + output: result.usage.completion_tokens, + total: result.usage.total_tokens, + } + output.cost = calculateCost( + request.model, + result.usage.prompt_tokens, + result.usage.completion_tokens + ) + if (result.thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) segment.thinkingContent = result.thinking + } + finalizeTiming() + }, }), }) } diff --git a/apps/sim/providers/prism/utils.ts b/apps/sim/providers/prism/utils.ts deleted file mode 100644 index a17b6858645..00000000000 --- a/apps/sim/providers/prism/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' -import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' -import type { AgentStreamEvent } from '@/providers/stream-events' - -export function createReadableStreamFromPrismStream( - prismStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void -): ReadableStream { - return createOpenAICompatibleAgentEventStream(prismStream, { - providerName: 'Prism', - onComplete: onComplete - ? (result) => onComplete(result.content, result.usage, result.thinking) - : undefined, - }) -}