diff --git a/.agents/skills/add-model/SKILL.md b/.agents/skills/add-model/SKILL.md index 5342b086f28..df17836a790 100644 --- a/.agents/skills/add-model/SKILL.md +++ b/.agents/skills/add-model/SKILL.md @@ -151,8 +151,8 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: -- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`) — visibility varies per Claude generation. Verify against the provider's thinking/display docs (e.g. Anthropic's "controlling thinking display" page and per-model "what's new" notes): generations that default `thinking.display` to `omitted` (Opus 4.7+, Sonnet 5, Fable 5) are `'summary'` — Sim opts back in with `display: 'summarized'` on agent-events runs; older generations that return full thinking deltas are `'full'`. `bun run agent-stream-docs:check` (CI) fails if the field is missing. -- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries, Bedrock → none, OpenAI-compat vendors → full raw CoT). Set it explicitly only when the model deviates from its family. +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. - After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. - Include the `streamed` value (with its source URL) in the verification report when set. diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md index b1366813d3f..9cef8634689 100644 --- a/.claude/commands/add-model.md +++ b/.claude/commands/add-model.md @@ -150,8 +150,8 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: -- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`) — visibility varies per Claude generation. Verify against the provider's thinking/display docs (e.g. Anthropic's "controlling thinking display" page and per-model "what's new" notes): generations that default `thinking.display` to `omitted` (Opus 4.7+, Sonnet 5, Fable 5) are `'summary'` — Sim opts back in with `display: 'summarized'` on agent-events runs; older generations that return full thinking deltas are `'full'`. `bun run agent-stream-docs:check` (CI) fails if the field is missing. -- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries, Bedrock → none, OpenAI-compat vendors → full raw CoT). Set it explicitly only when the model deviates from its family. +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. - After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. - Include the `streamed` value (with its source URL) in the verification report when set. diff --git a/.cursor/commands/add-model.md b/.cursor/commands/add-model.md index d542c6a872a..61b4a68a9cb 100644 --- a/.cursor/commands/add-model.md +++ b/.cursor/commands/add-model.md @@ -145,8 +145,8 @@ The Consumption Matrix (Step 2) tells you which capability *flags* are honored b If the entry has `capabilities.thinking` or `capabilities.reasoningEffort`, it appears in the autogenerated "Streamed thinking and tool calls" table on the Agent block docs page: -- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`) — visibility varies per Claude generation. Verify against the provider's thinking/display docs (e.g. Anthropic's "controlling thinking display" page and per-model "what's new" notes): generations that default `thinking.display` to `omitted` (Opus 4.7+, Sonnet 5, Fable 5) are `'summary'` — Sim opts back in with `display: 'summarized'` on agent-events runs; older generations that return full thinking deltas are `'full'`. `bun run agent-stream-docs:check` (CI) fails if the field is missing. -- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries, Bedrock → none, OpenAI-compat vendors → full raw CoT). Set it explicitly only when the model deviates from its family. +- **Anthropic-family (`anthropic`, `azure-anthropic`) thinking models MUST declare `capabilities.thinking.streamed`** (`'full' | 'summary' | 'none'`). Verify against Anthropic's current thinking-display and streaming docs: visible thinking returned by the API is summarized, including when Sim opts models whose default display is `omitted` into `display: 'summarized'` on agent-events runs, so current Claude thinking models use `'summary'`. Use `'full'` only if future official API docs explicitly guarantee raw thinking deltas. `bun run agent-stream-docs:check` (CI) fails if the field is missing. +- Other families usually omit the field and inherit the provider default in `getThinkingStreamVisibility` (Gemini/OpenAI → summaries; Bedrock/Meta → none; OpenAI-compatible vendors with documented reasoning fields → full deltas). Set it explicitly only when the model deviates from its family. - After inserting the entry, run `bun run agent-stream-docs:generate` and commit the regenerated `apps/docs/content/docs/en/workflows/blocks/agent.mdx` — CI diffs it. - Include the `streamed` value (with its source URL) in the verification report when set. diff --git a/apps/docs/content/docs/en/workflows/blocks/agent.mdx b/apps/docs/content/docs/en/workflows/blocks/agent.mdx index 0048e4858cf..aff05baf81e 100644 --- a/apps/docs/content/docs/en/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/agent.mdx @@ -80,8 +80,11 @@ Some settings live under advanced, or appear only for models that support them: - **Temperature.** How varied the output is. Stay low (0–0.3) when you need accuracy and repeatability, middle (around 0.5) for everyday work, higher (0.7+) when you want creative variety. - **Max output tokens.** Caps the response length. Defaults to the model's full limit. - **Reasoning effort / Thinking level.** For models with extended reasoning, how much the model thinks before answering. Higher is more thorough but slower and costs more tokens. +- **Prompt caching.** For Anthropic Claude models, reuses the system prompt and tool definitions between runs instead of re-reading them every time. Cached input costs a tenth of the normal rate, but writing the cache costs 1.25x, so leave it off for one-off runs and turn it on when the same agent runs repeatedly. The cache covers a prefix only if it reaches 1,024 tokens (2,048 on Haiku) — below that Anthropic ignores it and nothing changes. Entries expire after five minutes of no use. - **API key.** Your key for the chosen provider. Hidden on hosted Sim, which supplies one. +OpenAI and Gemini cache automatically at no extra cost and need no setting; their discount is already reflected in what you are charged. + ## Outputs After the agent runs, later blocks read its result by name: @@ -102,20 +105,19 @@ 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 **Anthropic, Azure Anthropic, Google, Vertex AI, DeepSeek, Groq, AWS Bedrock** models. Other providers run tools without live chips — tool results still appear in the block output when the run completes. +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. | Provider | Streamed thinking | Models | |----------|-------------------|--------| | OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `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 | Full thinking deltas | `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-opus-4-1`, `claude-sonnet-4-5`, `claude-haiku-4-5` | -| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5`, `claude-sonnet-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7` | +| Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `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-opus-4-1`, `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` | -| Azure Anthropic | Full thinking deltas | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | +| Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | | Google | Summaries only | `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | | Vertex AI | Summaries only | `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | -| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-chat`, `deepseek-reasoner` | +| DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-reasoner` | | Groq | Full thinking deltas | `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b`, `groq/openai/gpt-oss-safeguard-20b`, `groq/qwen/qwen3.6-27b` | -| Meta | Full thinking deltas | `muse-spark-1.1` | +| Meta | Not streamed | `muse-spark-1.1` | | Kimi | Full thinking deltas | `kimi-k2.6` | | Z.ai | Full thinking deltas | `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air` | @@ -142,6 +144,7 @@ The Agent reads the message from Start with `` and returns a result { question: "What is the difference between the tool usage controls (Auto, Force, None)?", answer: "In Auto, the model decides when to call a tool based on context. In Force, the model must call the tool on every run. In None, the tool is hidden from the model and never sent, which disables it without removing it from the block." }, { question: "How does the Response Format work?", answer: "It enforces structured output by providing a JSON Schema. When set, the model's response is constrained to match the schema exactly, and each field is read directly by downstream blocks using . Without a response format, the agent returns its standard outputs: content, model, tokens, and toolCalls." }, { question: "What does the Reasoning Effort / Thinking Level setting do?", answer: "They appear only for models that support extended reasoning. Reasoning Effort (OpenAI o-series and GPT-5 models) and Thinking Level (Anthropic Claude and Gemini models with thinking) control how much compute the model spends reasoning before responding. Higher levels produce more thorough answers but cost more tokens and take longer." }, + { question: "When should I turn on Prompt Caching?", answer: "Turn it on when the same agent runs repeatedly with a large, stable system prompt or tool set — cached input bills at a tenth of the normal input rate. Leave it off for one-off runs, because writing the cache costs 1.25x and nothing reads it back. The setting appears only for Anthropic Claude models; OpenAI and Gemini cache automatically with no setting and no write fee. Anthropic only caches a prefix of at least 1,024 tokens (2,048 on Haiku), and entries expire after five minutes of no use." }, { question: "How does max output tokens work with Anthropic models?", answer: "The Agent block uses each Anthropic model's full max output token limit by default (for example, 64,000 tokens). You can override this with the Max Output Tokens setting. For non-streaming requests that exceed the SDK's internal threshold, the provider automatically uses internal streaming to avoid timeouts." }, { question: "Can I use the Agent block with a custom or self-hosted model?", answer: "Yes. Use any Ollama or VLLM-compatible model by typing the model name directly into the model combobox, as long as it exposes a compatible API endpoint." }, ]} /> diff --git a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx index a3c9d72a627..7ea7e503648 100644 --- a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx @@ -12,18 +12,21 @@ Agent blocks can emit more than answer text while they run: **provider-exposed t Sim does **not** invent thinking for providers that do not stream it. Bedrock Converse, many OpenAI-compat models, and non-reasoning chat models stay text-only (plus tools when a live tool loop is wired). -## Dual gate (public chat / simple SSE) +## Protocol opt-in vs. event gates (public chat / simple SSE) -Thinking and tool frames leave the public chat or workflow simple-SSE path only when **both** are true: +Sending the header is a statement about the **client**: it understands v1 framing, so answer text can stream live and be retracted with `chunk_reset`. That alone does not expose anything — it only changes cadence, and it applies even when both event policies are off. -1. Deployment policy: chat `includeThinking` is enabled (default **off**). -2. Request opts in with header: +Thinking and tool frames need the header **plus** their own deployment policy: + +1. Thinking frames require chat `includeThinking` (default **off**). +2. Tool lifecycle frames require chat `includeToolCalls` (default **off**). +3. Both require the request to opt in with: ```http X-Sim-Stream-Protocol: agent-events-v1 ``` -Legacy clients that omit the header keep today’s text-only SSE even if the deployment has thinking enabled. The hosted chat UI always sends the header for its own deployments. +Legacy clients that omit the header keep today’s text-only SSE even if either deployment policy is enabled. The hosted chat UI always sends the header for its own deployments, so hosted chats stream token by token regardless of the toggles. ## Simple SSE frame shapes @@ -31,7 +34,7 @@ Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older c | Frame | Meaning | |-------|---------| -| `{ "blockId", "chunk": "…" }` | Answer text. Legacy clients receive settled final-turn text; opted-in clients receive it live as the model generates (see below) | +| `{ "blockId", "chunk": "…" }` | Answer text. Legacy clients receive settled final-turn text in one piece; opted-in clients receive it live as the model generates (see below) | | `{ "blockId", "event": "chunk_reset" }` | Opted-in only: discard the block’s streamed answer text — it belonged to a turn that resolved to tool calls | | `{ "blockId", "event": "thinking", "data": "…" }` | Thinking / reasoning summary delta | | `{ "blockId", "event": "tool", "phase": "start"\|"end", "id", "name", "status?" }` | Tool lifecycle (no args / results) | @@ -44,8 +47,8 @@ Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older c During a live tool loop, the model can’t be classified mid-turn: text it emits may turn out to be the final answer or preamble before a tool call (the stop reason arrives only at turn end). -- **Opted-in clients** (protocol header + `includeThinking`) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content. -- **Legacy clients** (no header) never see provisional text: only settled final-turn text is emitted as `chunk`, delivered when the turn completes. +- **Opted-in clients** (protocol header; no event policy required) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content. +- **Legacy clients** (no header) never see provisional text: only settled final-turn text is emitted as `chunk`, delivered in one piece when the turn completes. Honoring `chunk_reset` is what buys live cadence, so send the header if you want it. Logs, memory, and the block’s `content` output always contain final-turn text only — intermediate preamble is never persisted. @@ -68,9 +71,11 @@ When you click **Run** in the builder, the execution-events SSE path forwards th The terminal output panel shows Thinking / Tools chrome above the block output when those events arrive. PII redaction on block output still disables live forwarding (executor rule). -## Chat deployment toggle +## Chat deployment toggles + +In **Deploy → Chat**, enable **Include thinking** for provider-exposed thinking and **Include tool calls** for tool names and lifecycle status. The switches are independent, and both event types still require the protocol header. Neither switch affects answer-text cadence. -In **Deploy → Chat**, enable **Include thinking** so public chat can expose thinking/tool frames (still requires the protocol header). Redeploy / update the chat after changing Agent models or tools. +Tool arguments and results are never exposed to a public chat — not in lifecycle frames, and not through the terminal `final` envelope, where the block's own tool calls are reduced to the same name-and-lifecycle shape. The authenticated workflow API still returns full tool results. Redeploy or update the chat after changing Agent models or tools. ## Capability honesty (high level) @@ -80,7 +85,7 @@ Per-model support is generated from the model registry on the [Agent block page] |--------|----------|------------| | Anthropic / Azure Anthropic | Yes (incl. redacted blocks in traces). The newest Claude generations omit full thinking; Sim requests summarized thinking for them on streaming runs | Yes | | Gemini / Vertex | Yes when a thinking level is set (thought summaries requested on agent-events runs) | Yes | -| OpenAI Responses | Reasoning **summaries** when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Silent tool loop; tool chips arrive settled (start + end together) once tools finish, ahead of the streamed answer — no live in-progress chips yet | +| OpenAI Responses | Reasoning **summaries** when streamed (requires OpenAI organization verification; unverified orgs fall back to no summaries) | Yes | | OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams `reasoning` / `reasoning_content` | Live loop where wired (e.g. Groq, DeepSeek) | | Bedrock | Not invented | Yes when streaming tool loop is used | @@ -97,4 +102,4 @@ curl -N -X POST 'http://localhost:3000/api/chat/your-slug' \ -See also [Chat deployment](/docs/workflows/deployment/chat) for access control and the Include thinking setting. +See also [Chat deployment](/docs/workflows/deployment/chat) for access control and the event settings. diff --git a/apps/docs/content/docs/en/workflows/deployment/chat.mdx b/apps/docs/content/docs/en/workflows/deployment/chat.mdx index ffe2ac2c661..030f1ef62b9 100644 --- a/apps/docs/content/docs/en/workflows/deployment/chat.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/chat.mdx @@ -32,7 +32,8 @@ Configure the following fields, then click **Launch Chat**: | **Output** | Output fields from your workflow blocks returned as the chat response. At least one must be selected. | | **Welcome Message** | Greeting shown before the user sends their first message. Defaults to `"Hi there! How can I help you today?"`. | | **Access Control** | Controls who can access the chat. See [Access Control](#access-control) below. | -| **Include thinking** | When enabled, the hosted chat can show provider-exposed thinking and tool lifecycle (name + status). Requires the chat client to send `X-Sim-Stream-Protocol: agent-events-v1` (the hosted UI always does). Default is off. See [Agent stream events](/docs/workflows/deployment/agent-events). | +| **Include thinking** | When enabled, the hosted chat can show provider-exposed thinking. Requires the chat client to send `X-Sim-Stream-Protocol: agent-events-v1` (the hosted UI always does). Default is off. See [Agent stream events](/docs/workflows/deployment/agent-events). | +| **Include tool calls** | When enabled, the hosted chat can show tool names and lifecycle status independently of thinking. Tool arguments and results are never exposed. Requires the same stream protocol header and defaults to off. | ### Output Selection diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 99dfa6069c2..4ad1a4f0509 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -161,6 +161,7 @@ export const PUT = withRouteHandler( password: chat.password, outputConfigs: chat.outputConfigs, includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, }) .from(chat) .where( @@ -211,6 +212,7 @@ export const PUT = withRouteHandler( authType: deployment.authType, outputConfigs: deployment.outputConfigs, includeThinking: deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false, }) setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 928cba060ab..33a9bdd4a77 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -146,6 +146,7 @@ describe('Chat Identifier API Route', () => { }, outputConfigs: [{ blockId: 'block-1', path: 'output' }], includeThinking: false, + includeToolCalls: null, }, ] @@ -239,6 +240,7 @@ describe('Chat Identifier API Route', () => { expect(data).toHaveProperty('description', 'Test chat description') expect(data).toHaveProperty('customizations') expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat') + expect(data).toHaveProperty('includeToolCalls', false) }) it('should return 404 for non-existent identifier', async () => { @@ -405,13 +407,16 @@ describe('Chat Identifier API Route', () => { isSecureMode: true, workflowTriggerType: 'chat', includeThinking: false, + includeToolCalls: false, }), }) ) }, 10000) - it('enables agent events for the execution only when policy and protocol header agree', async () => { - const thinkingChatResult = [{ ...mockChatResult[0], includeThinking: true }] + it('preserves the legacy tool policy when includeToolCalls is null', async () => { + const thinkingChatResult = [ + { ...mockChatResult[0], includeThinking: true, includeToolCalls: null }, + ] dbChainMockFns.select.mockImplementation((fields: Record) => { if (fields && fields.isDeployed !== undefined) { return { @@ -440,7 +445,10 @@ describe('Chat Identifier API Route', () => { expect(response.status).toBe(200) const options = vi.mocked(createStreamingResponse).mock.calls[0][0] - expect(options.streamConfig).toMatchObject({ includeThinking: true }) + expect(options.streamConfig).toMatchObject({ + includeThinking: true, + includeToolCalls: true, + }) await options.executeFn({ onStream: vi.fn(), @@ -448,11 +456,67 @@ describe('Chat Identifier API Route', () => { abortSignal: new AbortController().signal, }) const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4] - expect(executeOptions).toMatchObject({ includeThinking: true, agentEvents: true }) + expect(executeOptions).toMatchObject({ + includeThinking: true, + includeToolCalls: true, + agentEvents: true, + }) + }, 10000) + + it('enables agent events for an independent tool-only policy', async () => { + const toolChatResult = [ + { ...mockChatResult[0], includeThinking: false, includeToolCalls: true }, + ] + dbChainMockFns.select.mockImplementation((fields: Record) => { + if (fields && fields.isDeployed !== undefined) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(mockWorkflowResult), + }), + }), + } + } + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue(toolChatResult), + }), + }), + } + }) + + const req = createMockNextRequest( + 'POST', + { input: 'Hello world' }, + { 'X-Sim-Stream-Protocol': 'agent-events-v1' } + ) + const response = await POST(req, { params: Promise.resolve({ identifier: 'test-chat' }) }) + expect(response.status).toBe(200) + + const options = vi.mocked(createStreamingResponse).mock.calls[0][0] + expect(options.streamConfig).toMatchObject({ + includeThinking: false, + includeToolCalls: true, + }) + + await options.executeFn({ + onStream: vi.fn(), + onBlockComplete: vi.fn(), + abortSignal: new AbortController().signal, + }) + const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4] + expect(executeOptions).toMatchObject({ + includeThinking: false, + includeToolCalls: true, + agentEvents: true, + }) }, 10000) it('keeps agent events off when the protocol header is missing, even with policy on', async () => { - const thinkingChatResult = [{ ...mockChatResult[0], includeThinking: true }] + const thinkingChatResult = [ + { ...mockChatResult[0], includeThinking: true, includeToolCalls: false }, + ] dbChainMockFns.select.mockImplementation((fields: Record) => { if (fields && fields.isDeployed !== undefined) { return { @@ -483,7 +547,11 @@ describe('Chat Identifier API Route', () => { abortSignal: new AbortController().signal, }) const executeOptions = vi.mocked(executeWorkflow).mock.calls[0][4] - expect(executeOptions).toMatchObject({ includeThinking: true, agentEvents: false }) + expect(executeOptions).toMatchObject({ + includeThinking: true, + includeToolCalls: false, + agentEvents: false, + }) }, 10000) it('should handle streaming response body correctly', async () => { diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index f04b8ffd881..7402147449f 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -28,6 +28,7 @@ interface ChatConfigSource { authType: string | null outputConfigs: unknown includeThinking?: boolean | null + includeToolCalls?: boolean | null } function toChatConfigResponse(deployment: ChatConfigSource) { @@ -39,6 +40,7 @@ function toChatConfigResponse(deployment: ChatConfigSource) { authType: deployment.authType, outputConfigs: deployment.outputConfigs, includeThinking: deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking ?? false, } } @@ -83,6 +85,7 @@ export const POST = withRouteHandler( allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, }) .from(chat) .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) @@ -278,8 +281,10 @@ export const POST = withRouteHandler( } const includeThinking = deployment.includeThinking ?? false + const includeToolCalls = deployment.includeToolCalls ?? includeThinking const agentEvents = shouldEmitAgentStreamEvents({ includeThinking, + includeToolCalls, requestHeaders: request.headers, }) const stream = await createStreamingResponse({ @@ -289,6 +294,7 @@ export const POST = withRouteHandler( isSecureMode: true, workflowTriggerType: 'chat', includeThinking, + includeToolCalls, }, executionId, workspaceId, @@ -314,6 +320,7 @@ export const POST = withRouteHandler( executionMode: 'stream', billingAttribution, includeThinking, + includeToolCalls, agentEvents, }, executionId @@ -324,10 +331,7 @@ export const POST = withRouteHandler( status: 200, headers: { ...SSE_HEADERS, - ...agentStreamProtocolResponseHeaders({ - includeThinking, - requestHeaders: request.headers, - }), + ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), }, }) return streamResponse @@ -366,6 +370,7 @@ export const GET = withRouteHandler( allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, }) .from(chat) .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index bedfbece4a6..4b021ae02f9 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -144,6 +144,8 @@ describe('Chat Edit API Route', () => { description: 'A test chat', password: 'encrypted-password', customizations: { primaryColor: '#000000' }, + includeThinking: true, + includeToolCalls: null, } mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) @@ -158,6 +160,7 @@ describe('Chat Edit API Route', () => { expect(data.title).toBe('Test Chat') expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat') expect(data.hasPassword).toBe(true) + expect(data.includeToolCalls).toBe(true) }) }) @@ -206,6 +209,8 @@ describe('Chat Edit API Route', () => { title: 'Test Chat', authType: 'public', workflowId: 'workflow-123', + includeThinking: true, + includeToolCalls: null, } mockCheckChatAccess.mockResolvedValue({ @@ -222,12 +227,47 @@ describe('Chat Edit API Route', () => { expect(response.status).toBe(200) expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ includeToolCalls: true }) + ) const data = await response.json() expect(data.id).toBe('chat-123') expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat') expect(data.message).toBe('Chat deployment updated successfully') }) + it('turns grandfathered tool calls off when the same update disables thinking', async () => { + // Before includeToolCalls existed, includeThinking gated tool frames too. + // Resolving the fallback against the stored value would materialize the + // stale `true` and silently leave tool frames on. + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' } }) + + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { + id: 'chat-123', + identifier: 'test-chat', + title: 'Test Chat', + authType: 'public', + workflowId: 'workflow-123', + includeThinking: true, + includeToolCalls: null, + }, + workspaceId: 'workspace-123', + }) + + const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { + method: 'PATCH', + body: JSON.stringify({ includeThinking: false }), + }) + const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) }) + + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ includeThinking: false, includeToolCalls: false }) + ) + }) + it('returns 403 when the updated auth type changes to a blocked mode', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-id' }, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index df98a1a7a14..a79b6ed32d4 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -62,6 +62,7 @@ export const GET = withRouteHandler( const result = { ...safeData, + includeToolCalls: safeData.includeToolCalls ?? safeData.includeThinking ?? false, chatUrl, hasPassword: !!password, } @@ -118,6 +119,7 @@ export const PATCH = withRouteHandler( allowedEmails, outputConfigs, includeThinking, + includeToolCalls, } = validatedData if (workflowId && workflowId !== existingChat[0].workflowId) { @@ -255,6 +257,17 @@ export const PATCH = withRouteHandler( updateData.includeThinking = includeThinking } + /** + * Grandfathering must resolve against the thinking value this request is + * setting, not the stored one. Before `includeToolCalls` existed, + * `includeThinking` gated tool frames too, so a partial update turning + * thinking off has to turn them off as well rather than materializing the + * stale `true` and silently leaving tool frames enabled. + */ + const effectiveIncludeThinking = includeThinking ?? existingChatRecord.includeThinking + updateData.includeToolCalls = + includeToolCalls ?? existingChatRecord.includeToolCalls ?? effectiveIncludeThinking ?? false + const emailCount = Array.isArray(updateData.allowedEmails) ? updateData.allowedEmails.length : undefined @@ -269,6 +282,7 @@ export const PATCH = withRouteHandler( emailCount, outputConfigsCount, includeThinking: updateData.includeThinking, + includeToolCalls: updateData.includeToolCalls, }) await db.update(chat).set(updateData).where(eq(chat.id, chatId)) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index ab4ebded2cf..e40afe0caee 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -454,6 +454,8 @@ describe('Chat API Route', () => { expect.objectContaining({ workflowId: 'workflow-123', userId: 'user-id', + includeThinking: false, + includeToolCalls: false, }) ) }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index b5a4ece32ea..74091053c61 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -32,7 +32,12 @@ export const GET = withRouteHandler(async (_request: NextRequest) => { .from(chat) .where(and(eq(chat.userId, session.user.id), isNull(chat.archivedAt))) - return createSuccessResponse({ deployments }) + return createSuccessResponse({ + deployments: deployments.map((deployment) => ({ + ...deployment, + includeToolCalls: deployment.includeToolCalls ?? deployment.includeThinking, + })), + }) } catch (error) { logger.error('Error fetching chat deployments:', error) return createErrorResponse(getErrorMessage(error, 'Failed to fetch chat deployments'), 500) @@ -69,6 +74,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { allowedEmails = [], outputConfigs = [], includeThinking = false, + includeToolCalls = false, } = parsed.data.body if (authType === 'password' && !password) { @@ -129,6 +135,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { allowedEmails, outputConfigs, includeThinking, + includeToolCalls, workspaceId: workflowRecord.workspaceId, }) diff --git a/apps/sim/app/api/providers/route.test.ts b/apps/sim/app/api/providers/route.test.ts index 97161bc7972..efb762da07e 100644 --- a/apps/sim/app/api/providers/route.test.ts +++ b/apps/sim/app/api/providers/route.test.ts @@ -111,6 +111,54 @@ describe('POST /api/providers', () => { ) }) + it('omits provisional stream output from the execution header', async () => { + mockExecuteProviderRequest.mockResolvedValue({ + streamFormat: 'agent-events-v1', + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'text_delta', text: 'hello', turn: 'final' }) + controller.close() + }, + }), + execution: { + success: true, + output: { + content: '', + model: 'gpt-4o', + tokens: { input: 0, output: 0, total: 0 }, + cost: { input: 0, output: 0, total: 0 }, + providerTiming: { + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.000Z', + duration: 0, + }, + }, + logs: [], + metadata: { + startTime: '2026-07-01T00:00:00.000Z', + endTime: '2026-07-01T00:00:00.000Z', + duration: 0, + }, + isStreaming: true, + }, + }) + + const res = await POST( + createMockRequest('POST', { + provider: 'openai', + model: 'gpt-4o', + workspaceId: 'ws-1', + stream: true, + }) + ) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('hello') + const executionHeader = JSON.parse(res.headers.get('X-Execution-Data') ?? '{}') + expect(executionHeader.output).toEqual({ model: 'gpt-4o' }) + expect(executionHeader.metadata).toEqual({ startTime: '2026-07-01T00:00:00.000Z' }) + }) + it('rejects an attribution header when the body has no workspaceId to validate against', async () => { const res = await POST( createMockRequest( diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 0794be56c31..7d16cf8ee16 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -274,44 +274,45 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const streamingExec = response as StreamingExecution logger.info(`[${requestId}] Received StreamingExecution from provider`) - // Extract the stream and execution data const executionData = streamingExec.execution - // agent-events-v1 is an object stream — project final-turn answer bytes for HTTP. const byteStream = projectStreamingExecutionToByteStream(streamingExec) - // Attach the execution data as a custom header - // We need to safely serialize the execution data to avoid circular references let executionDataHeader try { - // Create a safe version of execution data with the most important fields + const outputContent = executionData.output?.content + const outputTokens = executionData.output?.tokens + const hasSettledOutput = + Boolean(outputContent) || + Boolean(outputTokens?.total) || + Boolean(executionData.output?.toolCalls) const safeExecutionData = { success: executionData.success, output: { - // Sanitize content to remove non-ASCII characters that would cause ByteString errors - content: executionData.output?.content - ? String(executionData.output.content).replace(/[\u0080-\uFFFF]/g, '') - : '', model: executionData.output?.model, - tokens: executionData.output?.tokens || { - input: 0, - output: 0, - total: 0, - }, - // Sanitize any potential Unicode characters in tool calls - toolCalls: executionData.output?.toolCalls - ? sanitizeToolCalls(executionData.output.toolCalls) - : undefined, - providerTiming: executionData.output?.providerTiming, - cost: executionData.output?.cost, + ...(hasSettledOutput + ? { + content: String(outputContent ?? '').replace(/[\u0080-\uFFFF]/g, ''), + tokens: outputTokens, + toolCalls: executionData.output?.toolCalls + ? sanitizeToolCalls(executionData.output.toolCalls) + : undefined, + providerTiming: executionData.output?.providerTiming, + cost: executionData.output?.cost, + } + : {}), }, error: executionData.error, - logs: [], // Strip logs from header to avoid encoding issues + logs: [], metadata: { startTime: executionData.metadata?.startTime, - endTime: executionData.metadata?.endTime, - duration: executionData.metadata?.duration, + ...(hasSettledOutput + ? { + endTime: executionData.metadata?.endTime, + duration: executionData.metadata?.duration, + } + : {}), }, - isStreaming: true, // Always mark streaming execution data as streaming + isStreaming: true, blockId: executionData.logs?.[0]?.blockId, blockName: executionData.logs?.[0]?.blockName, blockType: executionData.logs?.[0]?.blockType, @@ -325,7 +326,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } - // Return the stream with execution data in a header return new Response(byteStream, { headers: { 'Content-Type': 'text/event-stream', diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index eae14fecd23..992dcd3dc47 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -253,6 +253,8 @@ export const POST = withRouteHandler( const executionMode = isApiCaller ? (persistedSnapshot.metadata.executionMode ?? 'sync') : undefined + const includeThinking = persistedSnapshot.metadata.includeThinking === true + const includeToolCalls = persistedSnapshot.metadata.includeToolCalls ?? includeThinking if (isApiCaller && executionMode === 'stream') { const stream = await createStreamingResponse({ @@ -260,7 +262,8 @@ export const POST = withRouteHandler( streamConfig: { selectedOutputs: persistedSnapshot.selectedOutputs, timeoutMs: preprocessResult.executionTimeout?.sync, - includeThinking: persistedSnapshot.metadata.includeThinking === true, + includeThinking, + includeToolCalls, }, executionId: enqueueResult.resumeExecutionId, workspaceId: workflow.workspaceId || undefined, @@ -282,10 +285,7 @@ export const POST = withRouteHandler( headers: { ...SSE_HEADERS, // Echo the negotiated stream protocol (same as the public chat route). - ...agentStreamProtocolResponseHeaders({ - includeThinking: persistedSnapshot.metadata.includeThinking === true, - requestHeaders: request.headers, - }), + ...agentStreamProtocolResponseHeaders({ requestHeaders: request.headers }), 'X-Execution-Id': enqueueResult.resumeExecutionId, }, }) diff --git a/apps/sim/app/api/tools/firecrawl/parse/route.ts b/apps/sim/app/api/tools/firecrawl/parse/route.ts index 610df2f45a2..ad88f88da0b 100644 --- a/apps/sim/app/api/tools/firecrawl/parse/route.ts +++ b/apps/sim/app/api/tools/firecrawl/parse/route.ts @@ -88,9 +88,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`[${requestId}] Firecrawl parse successful`) + const document = firecrawlData.data ?? firecrawlData return NextResponse.json({ success: true, - output: firecrawlData.data ?? firecrawlData, + output: + // Credits reported on the envelope would otherwise be dropped with it, + // leaving a paid parse with nothing to meter. + firecrawlData.creditsUsed != null + ? { ...document, creditsUsed: firecrawlData.creditsUsed } + : document, }) } catch (error) { const notReady = docNotReadyResponse(error) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index dae95ff2a1a..fdfe2fdc166 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -74,7 +74,8 @@ describe('Workflow Chat Status Route', () => { authType: 'public', allowedEmails: [], outputConfigs: [{ blockId: 'agent-1', path: 'content' }], - includeThinking: false, + includeThinking: true, + includeToolCalls: null, password: 'secret', isActive: true, }, @@ -89,6 +90,7 @@ describe('Workflow Chat Status Route', () => { expect(data.deployment.id).toBe('chat-1') expect(data.deployment.hasPassword).toBe(true) expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }]) - expect(data.deployment.includeThinking).toBe(false) + expect(data.deployment.includeThinking).toBe(true) + expect(data.deployment.includeToolCalls).toBe(true) }) }) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index 764f052f8c6..afa5f6ce6a7 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -53,6 +53,7 @@ export const GET = withRouteHandler( allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, password: chat.password, isActive: chat.isActive, }) @@ -73,6 +74,10 @@ export const GET = withRouteHandler( allowedEmails: deploymentResults[0].allowedEmails, outputConfigs: deploymentResults[0].outputConfigs, includeThinking: deploymentResults[0].includeThinking ?? false, + includeToolCalls: + deploymentResults[0].includeToolCalls ?? + deploymentResults[0].includeThinking ?? + false, hasPassword: Boolean(deploymentResults[0].password), } : null diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index d5a5b5d0aaf..0ba83187d81 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1445,8 +1445,9 @@ async function handleExecutePost( includeFileBase64, base64MaxBytes, timeoutMs: preprocessResult.executionTimeout?.sync, - // Workflow API has no chat includeThinking policy — thinking frames stay off. + /** Workflow API has no deployed-chat event policies, so agent-event frames stay off. */ includeThinking: false, + includeToolCalls: false, }, executionId, largeValueExecutionIds, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index d6efc583c74..4be07fe4eff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -86,6 +86,7 @@ const initialFormData: ChatFormData = { welcomeMessage: 'Hi there! How can I help you today?', selectedOutputBlocks: [], includeThinking: false, + includeToolCalls: false, } export function ChatDeploy({ @@ -197,6 +198,7 @@ export function ChatDeploy({ ) : [], includeThinking: existingChat.includeThinking ?? false, + includeToolCalls: existingChat.includeToolCalls ?? existingChat.includeThinking ?? false, }) if (existingChat.customizations?.imageUrl) { @@ -377,9 +379,6 @@ export function ChatDeploy({ -

- Allow this chat to stream model thinking when the client opts in. Off by default. -

+
+
+ +
+ updateField('includeToolCalls', checked)} + aria-label='Include tool calls' + /> +
+ { expect(serialized.metadata.billingAttribution).toEqual(billingAttribution) }) - it('preserves includeThinking on pause so chat resume can emit thinking SSE', () => { + it('preserves independent chat event policies across pause and resume', () => { const context = createContext({ metadata: { ...createContext().metadata, includeThinking: true, + includeToolCalls: false, executionMode: 'stream', }, }) @@ -203,13 +204,15 @@ describe('serializePauseSnapshot', () => { const serialized = JSON.parse(snapshot.snapshot) expect(serialized.metadata.includeThinking).toBe(true) + expect(serialized.metadata.includeToolCalls).toBe(false) expect(serialized.metadata.executionMode).toBe('stream') }) - it('omits includeThinking when the live run did not enable it', () => { + it('omits chat event policies when the live run did not enable them', () => { const snapshot = serializePauseSnapshot(createContext(), ['next-block']) const serialized = JSON.parse(snapshot.snapshot) expect(serialized.metadata.includeThinking).toBeUndefined() + expect(serialized.metadata.includeToolCalls).toBeUndefined() }) }) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index 67eae7bce09..d6025fcc0fd 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -252,9 +252,14 @@ export function serializePauseSnapshot( startTime: metadataFromContext?.startTime ?? new Date().toISOString(), isClientSession: metadataFromContext?.isClientSession, executionMode: metadataFromContext?.executionMode, - // Preserve deployed-chat thinking gate across HITL pause/resume. + /** Preserve deployed-chat thinking gate across HITL pause/resume. */ includeThinking: metadataFromContext?.includeThinking === true ? true : undefined, - // Preserve the run-level agent-events opt-in across HITL pause/resume. + /** Preserve false as distinct from a legacy snapshot with no independent tool policy. */ + includeToolCalls: + typeof metadataFromContext?.includeToolCalls === 'boolean' + ? metadataFromContext.includeToolCalls + : undefined, + /** Preserve the run-level agent-events opt-in across HITL pause/resume. */ agentEvents: metadataFromContext?.agentEvents === true ? true : undefined, } diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index db7cda77917..02723e9fcdf 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -52,6 +52,13 @@ export interface ExecutionMetadata { * resume can re-enable thinking frames without hardcoding false. */ includeThinking?: boolean + /** + * Deployed-chat tool lifecycle policy half of the SSE dual gate. Persisted so + * HITL resume can re-enable tool frames without coupling them to thinking. + * Explicit false distinguishes new snapshots from legacy snapshots that + * inherit the thinking policy. + */ + includeToolCalls?: boolean /** * Run-level agent-events opt-in. True only on surfaces that consume thinking * and tool lifecycle events (canvas Run, dual-gated public chat). Enables the diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 453bf344019..17fa2f967d6 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1875,11 +1875,28 @@ describe('AgentBlockHandler', () => { expect(providerCallArgs.billingAttribution).toEqual(billingAttribution) }) - it('forwards agentEvents to executeProviderRequest on opted-in streaming runs', async () => { + it('forwards streaming and agent events on opted-in runs', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Stream this', apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'search_files', + schema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + params: { + serverId: 'mcp-search-server', + toolName: 'search_files', + serverName: 'search', + }, + usageControl: 'auto' as const, + }, + ], } const streamingContext = { @@ -1899,11 +1916,28 @@ describe('AgentBlockHandler', () => { expect(providerCallArgs.agentEvents).toBe(true) }) - it('does not set agentEvents on runs without the run-level opt-in', async () => { + it('forwards ordinary streaming without exposing agent events', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Stream this', apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'search_files', + schema: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + params: { + serverId: 'mcp-search-server', + toolName: 'search_files', + serverName: 'search', + }, + usageControl: 'auto' as const, + }, + ], } const streamingContext = { @@ -1918,6 +1952,7 @@ describe('AgentBlockHandler', () => { expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1] + expect(providerCallArgs.stream).toBe(true) expect(providerCallArgs.agentEvents).toBe(false) }) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 48d7f24c851..fc721b990f2 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -46,7 +46,6 @@ import { shouldUseLargeFilePath, supportsFileAttachments, } from '@/providers/attachments' -import { supportsStreamingToolCalls } from '@/providers/streaming-tool-loop-shared' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { filterSchemaForLLM, type ToolSchema } from '@/tools/params' @@ -955,19 +954,10 @@ export class AgentBlockHandler implements BlockHandler { reasoningEffort: inputs.reasoningEffort, verbosity: inputs.verbosity, thinkingLevel: inputs.thinkingLevel, + promptCaching: inputs.promptCaching === true, previousInteractionId: inputs.previousInteractionId, - /** - * Agent-events opt-in and live tool lifecycle. Both are gated on the - * run-level {@link ExecutionMetadata.agentEvents} flag so runs without an - * agent-events consumer keep the exact pre-agent-events provider - * behavior (legacy loops, unchanged request payloads). - */ + /** Agent-events remains the opt-in for exposing thinking and tool lifecycle events. */ agentEvents: streaming && ctx.metadata?.agentEvents === true, - streamToolCalls: - streaming && - ctx.metadata?.agentEvents === true && - formattedTools.length > 0 && - supportsStreamingToolCalls(providerId), } } @@ -1041,9 +1031,11 @@ export class AgentBlockHandler implements BlockHandler { reasoningEffort: providerRequest.reasoningEffort, verbosity: providerRequest.verbosity, thinkingLevel: providerRequest.thinkingLevel, + promptCaching: providerRequest.promptCaching, + // Stable per-block identity; providers use it to route cache lookups. + blockId: block.id, previousInteractionId: providerRequest.previousInteractionId, agentEvents: providerRequest.agentEvents, - streamToolCalls: providerRequest.streamToolCalls, abortSignal: ctx.abortSignal, }) diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 39329739a43..4c311b190dd 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -39,6 +39,7 @@ export interface AgentInputs { reasoningEffort?: string verbosity?: string thinkingLevel?: string + promptCaching?: boolean files?: unknown } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 5afc6b640df..b992730f0cc 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -163,6 +163,34 @@ describe('EvaluatorBlockHandler', () => { }) }) + it('bills the cost the provider proxy decided rather than recomputing it', async () => { + // The proxy already resolved key provenance and the margin; recomputing + // here would re-charge a BYOK caller the proxy correctly zeroed. + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + content: JSON.stringify({ score1: 5, score2: 8 }), + model: 'mock-model', + tokens: { input: 50, output: 10, total: 60 }, + cost: { input: 0.001, output: 0.0005, total: 0.0015 }, + timing: { total: 200 }, + }), + }) + ) + + const result = await handler.execute(mockContext, mockBlock, { + content: 'This is the content to evaluate.', + }) + + expect((result as { cost: unknown }).cost).toEqual({ + input: 0.001, + output: 0.0005, + total: 0.0015, + }) + }) + it('should process JSON string content correctly', async () => { const contentObj = { text: 'Evaluate this JSON.', value: 42 } const inputs = { diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 3ab07bc3653..1f96fff1d6f 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -6,7 +6,8 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' -import { calculateCost, getProviderFromModel } from '@/providers/utils' +import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('EvaluatorBlockHandler') @@ -154,7 +155,7 @@ export class EvaluatorBlockHandler implements BlockHandler { const outputTokens = result.tokens?.output || result.tokens?.completion || DEFAULTS.TOKENS.COMPLETION - const costCalculation = calculateCost(result.model, inputTokens, outputTokens, false) + const cost = resolveProxiedModelCost(result.cost) return { content: inputs.content, @@ -165,9 +166,9 @@ export class EvaluatorBlockHandler implements BlockHandler { total: result.tokens?.total || DEFAULTS.TOKENS.TOTAL, }, cost: { - input: costCalculation.input, - output: costCalculation.output, - total: costCalculation.total, + input: cost.input, + output: cost.output, + total: cost.total, }, ...metricScores, } diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts index cb1c6bbcde2..90f8f25befa 100644 --- a/apps/sim/executor/handlers/pi/keys.test.ts +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -51,13 +51,21 @@ describe('computePiCost', () => { }) it('returns zero cost for BYOK keys without billing', () => { - expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 }) + expect(computePiCost('claude', 100, 200, true)).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) expect(mockCalculateCost).not.toHaveBeenCalled() }) it('returns zero cost for non-billable models', () => { mockShouldBill.mockReturnValue(false) - expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 }) + expect(computePiCost('local-model', 100, 200, false)).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) expect(mockCalculateCost).not.toHaveBeenCalled() }) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts index 870686ac3ee..14ebbcc148b 100644 --- a/apps/sim/executor/handlers/pi/keys.ts +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -10,14 +10,13 @@ import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent' import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok' -import { getCostMultiplier } from '@/lib/core/config/env-flags' +import { calculateBillableModelCost } from '@/providers/cost-policy' import type { PiSupportedProvider } from '@/providers/pi-provider-configs' import { getPiProviderApiKeyEnvVar, getPiWorkspaceBYOKProviderId, isPiSupportedProvider, } from '@/providers/pi-providers' -import { calculateCost, shouldBillModelUsage } from '@/providers/utils' /** Resolved provider key and BYOK flag for a Pi run. */ interface PiKeyResolution { @@ -74,11 +73,7 @@ export function computePiCost( outputTokens: number, isBYOK: boolean ) { - if (isBYOK || !shouldBillModelUsage(model)) { - return { input: 0, output: 0, total: 0 } - } - const multiplier = getCostMultiplier() - return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier) + return calculateBillableModelCost(model, inputTokens, outputTokens, { isBYOK }) } /** diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 299eaf6e8dc..3c57fe2de8f 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -204,6 +204,34 @@ describe('RouterBlockHandler', () => { }) }) + it('bills the cost the provider proxy decided rather than recomputing it', async () => { + // The proxy already resolved key provenance and the margin; recomputing + // here would re-charge a BYOK caller the proxy correctly zeroed. + mockFetch.mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + content: 'target-block-1', + model: 'mock-model', + tokens: { input: 100, output: 5, total: 105 }, + cost: { input: 0.004, output: 0.002, total: 0.006 }, + timing: { total: 300 }, + }), + }) + ) + + const result = await handler.execute(mockContext, mockBlock, { + prompt: 'Choose the best option.', + }) + + expect((result as { cost: unknown }).cost).toEqual({ + input: 0.004, + output: 0.002, + total: 0.006, + }) + }) + it('should throw error if target block is missing', async () => { const inputs = { prompt: 'Test' } mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2] diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 12042918b25..55184c40611 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -13,7 +13,8 @@ import { import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' -import { calculateCost, getProviderFromModel } from '@/providers/utils' +import { resolveProxiedModelCost } from '@/providers/cost-policy' +import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('RouterBlockHandler') @@ -145,12 +146,7 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = calculateCost( - result.model, - tokens.input || DEFAULTS.TOKENS.PROMPT, - tokens.output || DEFAULTS.TOKENS.COMPLETION, - false - ) + const cost = resolveProxiedModelCost(result.cost) return { prompt: inputs.prompt, @@ -331,12 +327,7 @@ export class RouterBlockHandler implements BlockHandler { total: DEFAULTS.TOKENS.TOTAL, } - const cost = calculateCost( - result.model, - tokens.input || DEFAULTS.TOKENS.PROMPT, - tokens.output || DEFAULTS.TOKENS.COMPLETION, - false - ) + const cost = resolveProxiedModelCost(result.cost) return { context: inputs.context, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 09f41dad8f9..653d1c51d8e 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -185,7 +185,7 @@ export interface BlockToolCall { error?: string arguments?: Record input?: Record - result?: Record + result?: unknown output?: Record } diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 046bfcc9dbb..b0e4c37a4ab 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -177,6 +177,8 @@ export interface ChatFormData { selectedOutputBlocks: string[] /** When true, thinking may be streamed to clients that opt into agent-events-v1. Default false. */ includeThinking: boolean + /** When true, tool lifecycle may be streamed to opted-in clients. Default false. */ + includeToolCalls: boolean } /** @@ -266,6 +268,7 @@ function buildChatPayload( formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, includeThinking: formData.includeThinking, + includeToolCalls: formData.includeToolCalls, } } diff --git a/apps/sim/lib/api/contracts/chats.include-thinking.test.ts b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts index 903e99e0baa..d569a9b44e2 100644 --- a/apps/sim/lib/api/contracts/chats.include-thinking.test.ts +++ b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts @@ -9,8 +9,8 @@ import { } from '@/lib/api/contracts/chats' import { chatDetailSchema } from '@/lib/api/contracts/deployments' -describe('chat includeThinking contracts (Step 4)', () => { - it('create defaults includeThinking to false', () => { +describe('chat agent-event policy contracts', () => { + it('create defaults both policies to false', () => { const parsed = createChatBodySchema.parse({ workflowId: 'wf-1', identifier: 'my-chat', @@ -21,9 +21,10 @@ describe('chat includeThinking contracts (Step 4)', () => { }, }) expect(parsed.includeThinking).toBe(false) + expect(parsed.includeToolCalls).toBe(false) }) - it('create accepts includeThinking true', () => { + it('create accepts independent policy values', () => { const parsed = createChatBodySchema.parse({ workflowId: 'wf-1', identifier: 'my-chat', @@ -33,17 +34,22 @@ describe('chat includeThinking contracts (Step 4)', () => { welcomeMessage: 'Hi', }, includeThinking: true, + includeToolCalls: false, }) expect(parsed.includeThinking).toBe(true) + expect(parsed.includeToolCalls).toBe(false) }) - it('update accepts includeThinking toggle', () => { + it('update accepts independent policy toggles', () => { expect(updateChatBodySchema.parse({ includeThinking: true }).includeThinking).toBe(true) expect(updateChatBodySchema.parse({ includeThinking: false }).includeThinking).toBe(false) expect(updateChatBodySchema.parse({ title: 'x' }).includeThinking).toBeUndefined() + expect(updateChatBodySchema.parse({ includeToolCalls: true }).includeToolCalls).toBe(true) + expect(updateChatBodySchema.parse({ includeToolCalls: false }).includeToolCalls).toBe(false) + expect(updateChatBodySchema.parse({ title: 'x' }).includeToolCalls).toBeUndefined() }) - it('chat detail and deployed config expose includeThinking (default false)', () => { + it('chat detail and deployed config expose both policies', () => { const detail = chatDetailSchema.parse({ id: 'chat-1', identifier: 'my-chat', @@ -57,12 +63,15 @@ describe('chat includeThinking contracts (Step 4)', () => { hasPassword: false, }) expect(detail.includeThinking).toBe(false) + expect(detail.includeToolCalls).toBe(false) const detailOn = chatDetailSchema.parse({ ...detail, includeThinking: true, + includeToolCalls: true, }) expect(detailOn.includeThinking).toBe(true) + expect(detailOn.includeToolCalls).toBe(true) const config = deployedChatConfigSchema.parse({ id: 'chat-1', @@ -72,5 +81,6 @@ describe('chat includeThinking contracts (Step 4)', () => { authType: 'public', }) expect(config.includeThinking).toBe(false) + expect(config.includeToolCalls).toBe(false) }) }) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index 9aa37e99723..528e20c5c28 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -43,6 +43,8 @@ export const createChatBodySchema = z.object({ outputConfigs: z.array(chatOutputConfigSchema).optional().default([]), /** When true, clients may receive thinking SSE if they also send the protocol header. Default off. */ includeThinking: z.boolean().optional().default(false), + /** When true, clients may receive tool lifecycle SSE if they also send the protocol header. */ + includeToolCalls: z.boolean().optional().default(false), }) export type CreateChatBody = z.input @@ -61,6 +63,7 @@ export const updateChatBodySchema = z.object({ allowedEmails: z.array(z.string()).optional(), outputConfigs: z.array(chatOutputConfigSchema).optional(), includeThinking: z.boolean().optional(), + includeToolCalls: z.boolean().optional(), }) export type UpdateChatBody = z.input @@ -106,6 +109,8 @@ export const deployedChatConfigSchema = z.object({ ), /** Policy for thinking SSE; clients still need the X-Sim-Stream-Protocol opt-in. */ includeThinking: z.preprocess((value) => value ?? false, z.boolean()), + /** Policy for tool lifecycle SSE; clients still need the protocol opt-in. */ + includeToolCalls: z.preprocess((value) => value ?? false, z.boolean()), }) export type DeployedChatConfig = z.output @@ -214,9 +219,12 @@ export const deployedChatPostContract = defineRouteContract({ params: chatIdentifierParamsSchema, body: deployedChatPostBodySchema, response: { - // Message posts return SSE (`text/event-stream`). Auth-only POSTs use - // authenticateDeployedChatContract (JSON). Terminal frames: `final` or one - // `error`, then `[DONE]`. Thinking frames require includeThinking + protocol header. + /** + * Message posts return SSE (`text/event-stream`). Auth-only POSTs use + * authenticateDeployedChatContract (JSON). Terminal frames: `final` or one + * `error`, then `[DONE]`. Thinking and tool frames use independent deployment + * policies; both require the protocol header. + */ mode: 'stream', }, }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index a4689c1c133..db8e82adb6b 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -177,6 +177,7 @@ export const chatDetailSchema = z.object({ ) ), includeThinking: z.preprocess((value) => value ?? false, z.boolean()), + includeToolCalls: z.preprocess((value) => value ?? false, z.boolean()), customizations: z.preprocess( (value) => value ?? undefined, z diff --git a/apps/sim/lib/api/contracts/providers.ts b/apps/sim/lib/api/contracts/providers.ts index 0e941a23ca7..7c40ed81154 100644 --- a/apps/sim/lib/api/contracts/providers.ts +++ b/apps/sim/lib/api/contracts/providers.ts @@ -316,6 +316,9 @@ const executeProviderResponseSchema = z input: z.number().optional(), output: z.number().optional(), total: z.number().optional(), + /** Prompt-cache buckets, reported separately from base input tokens. */ + cacheRead: z.number().optional(), + cacheWrite: z.number().optional(), }) .optional(), toolCalls: z.array(z.record(z.string(), z.unknown())).optional(), diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 406ef9f0953..9316dff9070 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -182,7 +182,6 @@ interface OrchestratorRequest { fileAttachments?: FileAttachment[] commands?: string[] provider?: string - streamToolCalls?: boolean version?: string prefetch?: boolean userName?: string diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 0622e78d977..14d4ef9694c 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -309,6 +309,8 @@ export async function executeDeployChat( outputConfigs: (existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [], includeThinking: existing[0].includeThinking ?? false, + includeToolCalls: + existing[0].includeToolCalls ?? existing[0].includeThinking ?? false, welcomeMessage: (existing[0].customizations as { welcomeMessage?: string } | null) ?.welcomeMessage || 'Hi there! How can I help you today?', @@ -400,6 +402,15 @@ export async function executeDeployChat( typeof params.includeThinking === 'boolean' ? params.includeThinking : (existingDeployment?.includeThinking ?? false) + /** + * Grandfathers off the thinking value this call resolves to, not the stored + * one: before `includeToolCalls` existed, `includeThinking` gated tool + * frames too, so turning thinking off must turn them off with it. + */ + const resolvedIncludeToolCalls = + typeof params.includeToolCalls === 'boolean' + ? params.includeToolCalls + : (existingDeployment?.includeToolCalls ?? resolvedIncludeThinking) const welcomeMessage = typeof params.welcomeMessage === 'string' ? params.welcomeMessage @@ -444,6 +455,7 @@ export async function executeDeployChat( allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, includeThinking: resolvedIncludeThinking, + includeToolCalls: resolvedIncludeToolCalls, workspaceId: workflowRecord.workspaceId, }) @@ -497,6 +509,7 @@ export async function executeDeployChat( allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, includeThinking: resolvedIncludeThinking, + includeToolCalls: resolvedIncludeToolCalls, welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', primaryColor: params.customizations?.primaryColor || diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 1218b25440b..6ca6bdb4a26 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -62,6 +62,7 @@ export async function executeCheckDeploymentStatus( allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, password: chat.password, customizations: chat.customizations, }) @@ -105,6 +106,7 @@ export async function executeCheckDeploymentStatus( allowedEmails: chatDeploy[0]?.allowedEmails || null, outputConfigs: chatDeploy[0]?.outputConfigs || null, includeThinking: chatDeploy[0]?.includeThinking ?? false, + includeToolCalls: chatDeploy[0]?.includeToolCalls ?? chatDeploy[0]?.includeThinking ?? false, welcomeMessage: chatCustomizations.welcomeMessage || null, primaryColor: chatCustomizations.primaryColor || null, hasPassword: Boolean(chatDeploy[0]?.password), diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index a1c585bde4a..a111a87f854 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -159,6 +159,7 @@ export interface DeployChatParams { allowedEmails?: string[] outputConfigs?: unknown[] includeThinking?: boolean + includeToolCalls?: boolean } export interface DeployMcpParams { diff --git a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts index 3b7e874847c..4e7cafbef42 100644 --- a/apps/sim/lib/logs/execution/trace-spans/span-factory.ts +++ b/apps/sim/lib/logs/execution/trace-spans/span-factory.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import type { ProviderTiming, TraceSpan } from '@/lib/logs/types' import { isConditionBlockType, @@ -17,6 +18,12 @@ const logger = createLogger('SpanFactory') /** A BlockLog that has already passed the id/type validity check. */ type ValidBlockLog = BlockLog & { blockType: string } +/** Converts arbitrary tool results to the object shape expected by trace spans. */ +function normalizeTraceOutput(value: unknown): Record | undefined { + if (value === undefined) return undefined + return isRecordLike(value) ? value : { value } +} + /** * Creates a TraceSpan from a BlockLog. Returns null for invalid logs. * @@ -190,6 +197,7 @@ function buildChildrenFromTimeSegments( const currentIndex = toolCallIndices.get(normalizedName) ?? 0 const match = callsForName[currentIndex] toolCallIndices.set(normalizedName, currentIndex + 1) + const output = normalizeTraceOutput(match?.result ?? match?.output) const toolChild: TraceSpan = { id: `${span.id}-segment-${index}`, @@ -200,9 +208,7 @@ function buildChildrenFromTimeSegments( endTime: segmentEndTime, status: match?.error || segment.errorMessage ? 'error' : 'success', input: match?.arguments ?? match?.input, - output: match?.error - ? { error: match.error, ...(match.result ?? match.output ?? {}) } - : (match?.result ?? match?.output), + output: match?.error ? { error: match.error, ...output } : output, } if (segment.toolCallId) toolChild.toolCallId = segment.toolCallId if (segment.errorType) toolChild.errorType = segment.errorType @@ -269,6 +275,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS return toolCalls.map((tc, index) => { const startTime = tc.startTime ?? log.startedAt const endTime = tc.endTime ?? log.endedAt + const output = normalizeTraceOutput(tc.result ?? tc.output) return { id: `${span.id}-tool-${index}`, name: stripCustomToolPrefix(tc.name ?? 'unnamed-tool'), @@ -278,9 +285,7 @@ function buildChildrenFromToolCalls(span: TraceSpan, log: ValidBlockLog): TraceS endTime, status: tc.error ? 'error' : 'success', input: tc.arguments ?? tc.input, - output: tc.error - ? { error: tc.error, ...(tc.result ?? tc.output ?? {}) } - : (tc.result ?? tc.output), + output: tc.error ? { error: tc.error, ...output } : output, } }) } diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 2b2fff6e703..f763e08b995 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -196,6 +196,34 @@ describe('buildTraceSpans', () => { expect(secondToolCall.output).toEqual({ status: 200, data: 'response' }) }) + it.concurrent('normalizes scalar tool results only at the trace display boundary', () => { + const mockExecutionResult: ExecutionResult = { + success: true, + output: { content: 'Final output' }, + logs: [ + { + blockId: 'agent-scalar', + blockName: 'Scalar Agent', + blockType: 'agent', + startedAt: '2024-01-01T10:00:00.000Z', + endedAt: '2024-01-01T10:00:01.000Z', + durationMs: 1000, + success: true, + output: { + toolCalls: { + list: [{ name: 'boolean_tool', result: false }], + count: 1, + }, + }, + }, + ], + } + + const { traceSpans } = buildTraceSpans(mockExecutionResult) + + expect(traceSpans[0].children?.[0].output).toEqual({ value: false }) + }) + it.concurrent( 'extracts tool calls from agent block output with direct toolCalls array format', () => { diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts index c4fd6d47d1b..4f6f980bd39 100644 --- a/apps/sim/lib/monitoring/metrics.ts +++ b/apps/sim/lib/monitoring/metrics.ts @@ -41,7 +41,8 @@ const MAX_BUFFER = 10_000 // hard cap; drop oldest beyond this if flushing stall type ThrottleReason = 'billing_actor_limit' | 'upstream_retries_exhausted' type QueueReason = 'actor_requests' | 'dimension' | 'queue_position' -type FailureReason = 'rate_limited' | 'auth' | 'other' +/** `metering` marks a provider call that succeeded but could not be priced. */ +type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering' // Deployed envs (app + trigger worker) carry static AWS creds; local dev does // not. No creds → no-op, so recorders stay always-safe to call (same contract diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 5c77fef1472..639ef051945 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -56,6 +56,8 @@ export interface ExecuteWorkflowOptions { billingAttribution?: BillingAttributionSnapshot /** Deployed-chat thinking policy; persisted on the snapshot for resume. */ includeThinking?: boolean + /** Deployed-chat tool lifecycle policy; persisted on the snapshot for resume. */ + includeToolCalls?: boolean /** * Run-level agent-events opt-in (see {@link ExecutionMetadata.agentEvents}). * Callers set this only when the surface consumes thinking/tool events. @@ -119,6 +121,10 @@ export async function executeWorkflow( fileKeys: streamConfig?.fileKeys, executionMode: streamConfig?.executionMode, includeThinking: streamConfig?.includeThinking === true ? true : undefined, + includeToolCalls: + typeof streamConfig?.includeToolCalls === 'boolean' + ? streamConfig.includeToolCalls + : undefined, agentEvents: streamConfig?.agentEvents === true ? true : undefined, } diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 13b5a51a371..3bfe35d3de7 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -31,6 +31,8 @@ export interface ChatDeployPayload { outputConfigs?: Array<{ blockId: string; path: string }> /** When true, public SSE may expose thinking if the client also opts into agent-events-v1. */ includeThinking?: boolean + /** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */ + includeToolCalls?: boolean workspaceId?: string | null } @@ -63,6 +65,7 @@ export async function performChatDeploy( allowedEmails = [], outputConfigs = [], includeThinking = false, + includeToolCalls = false, } = params const customizations = { @@ -145,6 +148,7 @@ export async function performChatDeploy( allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, includeThinking, + includeToolCalls, updatedAt: new Date(), }) .where(eq(chat.id, chatId)) @@ -164,6 +168,7 @@ export async function performChatDeploy( allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, includeThinking, + includeToolCalls, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts index a001ad1742a..b3c17aeb694 100644 --- a/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts @@ -5,8 +5,10 @@ import { describe, expect, it } from 'vitest' import { AGENT_STREAM_PROTOCOL_HEADER, AGENT_STREAM_PROTOCOL_V1, + clientAcceptsAgentStreamProtocol, isChatChunkFrame, isChatChunkResetFrame, + isChatToolFrame, shouldEmitAgentStreamEvents, } from '@/lib/workflows/streaming/agent-stream-protocol' @@ -14,6 +16,36 @@ function headers(init?: Record): Headers { return new Headers(init) } +describe('clientAcceptsAgentStreamProtocol', () => { + it('depends on the header alone, never on a deployment policy', () => { + expect( + clientAcceptsAgentStreamProtocol( + headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }) + ) + ).toBe(true) + expect( + clientAcceptsAgentStreamProtocol(headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' V1 ' })) + ).toBe(false) + expect(clientAcceptsAgentStreamProtocol(headers())).toBe(false) + }) +}) + +describe('tool frame guard', () => { + const base = { event: 'tool', blockId: 'agent-1', phase: 'end', id: 'call_1', name: 'search' } + + it('accepts every documented terminal status and a start frame without one', () => { + expect(isChatToolFrame({ ...base, phase: 'start' })).toBe(true) + for (const status of ['success', 'error', 'cancelled']) { + expect(isChatToolFrame({ ...base, status })).toBe(true) + } + }) + + it('rejects an unrecognized status instead of letting it settle as success', () => { + expect(isChatToolFrame({ ...base, status: 'timeout' })).toBe(false) + expect(isChatToolFrame({ ...base, status: 42 })).toBe(false) + }) +}) + describe('chunk_reset frame guard', () => { it('identifies reset frames and keeps them out of the chunk guard', () => { const reset = { blockId: 'agent-1', event: 'chunk_reset' } @@ -31,21 +63,24 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: false, + includeToolCalls: false, requestHeaders: headers(), }) ).toBe(false) expect( shouldEmitAgentStreamEvents({ includeThinking: undefined, + includeToolCalls: undefined, requestHeaders: headers(), }) ).toBe(false) }) - it('requires both includeThinking and protocol header', () => { + it('requires the protocol header and at least one event policy', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: true, + includeToolCalls: false, requestHeaders: headers(), }) ).toBe(false) @@ -53,6 +88,7 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: false, + includeToolCalls: false, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), }) ).toBe(false) @@ -60,6 +96,15 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: true, + includeToolCalls: false, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(true) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + includeToolCalls: true, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), }) ).toBe(true) @@ -69,13 +114,15 @@ describe('shouldEmitAgentStreamEvents', () => { expect( shouldEmitAgentStreamEvents({ includeThinking: true, + includeToolCalls: false, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' Agent-Events-V1 ' }), }) ).toBe(true) expect( shouldEmitAgentStreamEvents({ - includeThinking: true, + includeThinking: false, + includeToolCalls: true, requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: 'text, agent-events-v1', }), diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts index f55ebc18da3..62f46334cd5 100644 --- a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts @@ -2,23 +2,29 @@ * Public agent stream protocol: header negotiation and the wire frame * vocabulary for the public chat / simple SSE surface. * - * Exposure rule (locked) for public chat / simple SSE: - * emit thinking/tool SSE frames iff - * deployment.includeThinking === true - * AND request opts into agent-events-v1 via {@link AGENT_STREAM_PROTOCOL_HEADER} + * Two independent things are negotiated here: + * + * 1. Client capability — {@link AGENT_STREAM_PROTOCOL_HEADER} means the client + * understands v1 framing, so answer text may stream live and be retracted + * with `chunk_reset`. No deployment policy is involved. + * 2. Event exposure — thinking frames need `deployment.includeThinking`, tool + * frames need `deployment.includeToolCalls`, and both additionally need the + * client capability above. + * + * Keeping these separate is what lets a chat with both policies off still + * stream its answer token by token, exactly as it did before agent events. * * Canvas draft runs (execution-events) forward the same sink as live-only - * `stream:thinking` / `stream:tool` events without the includeThinking gate; + * `stream:thinking` / `stream:tool` events without the deployment policy gates; * the executor still disables the sink when block-output PII redaction is on. * - * Legacy clients omitting the header stay text-only even when the deployment - * has thinking enabled. Deployed chat UI always sends the header when loading - * its own deployment. + * Legacy clients omitting the header stay on settled final-turn text and never + * see thinking or tools. The deployed chat UI always sends the header. * * See docs: workflows/deployment/agent-events. */ -import type { ToolCallEndStatus } from '@/providers/stream-events' +import { isToolCallEndStatus, type ToolCallEndStatus } from '@/providers/stream-events' export const AGENT_STREAM_PROTOCOL_HEADER = 'x-sim-stream-protocol' as const @@ -30,9 +36,9 @@ export type AgentStreamProtocol = typeof AGENT_STREAM_PROTOCOL_V1 * Answer text. The only frame legacy clients append to the answer. * * Legacy clients (no protocol header) receive only settled final-turn text. - * Dual-gated clients receive answer text live as it streams — including text - * from a turn that may later resolve to tool calls — reconciled by - * {@link ChatStreamChunkResetFrame} when a turn turns out to be intermediate. + * Protocol-negotiated clients receive answer text live as it streams — + * including text from a turn that may later resolve to tool calls — reconciled + * by {@link ChatStreamChunkResetFrame} when a turn turns out to be intermediate. */ export interface ChatStreamChunkFrame { blockId: string @@ -40,23 +46,24 @@ export interface ChatStreamChunkFrame { } /** - * Dual-gated only: the live-streamed answer text for `blockId` belonged to an - * intermediate turn (tool calls follow). Clients discard the block's - * accumulated answer text; the final turn re-streams after tools settle. + * Negotiated agent-events streams only: the live-streamed answer text for + * `blockId` belonged to an intermediate turn (tool calls follow). Clients + * discard the block's accumulated answer text; the final turn re-streams after + * tools settle. */ export interface ChatStreamChunkResetFrame { blockId: string event: 'chunk_reset' } -/** Thinking / reasoning-summary delta. Dual-gated; never reuses `chunk`. */ +/** Thinking / reasoning-summary delta. Thinking-policy gated; never reuses `chunk`. */ export interface ChatStreamThinkingFrame { blockId: string event: 'thinking' data: string } -/** Tool lifecycle (name + status only — never args or results). Dual-gated. */ +/** Tool lifecycle (name + status only — never args or results). Tool-policy gated. */ export interface ChatStreamToolFrame { blockId: string event: 'tool' @@ -142,7 +149,11 @@ export function isChatToolFrame(value: unknown): value is ChatStreamToolFrame { typeof value.id === 'string' && value.id.length > 0 && typeof value.name === 'string' && - value.name.length > 0 + value.name.length > 0 && + // An unrecognized status is a protocol violation, not a success. Rejecting + // the frame leaves the chip running for the terminal settle (which knows + // the run's real outcome) instead of rendering it green on a guess. + (value.status === undefined || isToolCallEndStatus(value.status)) ) } @@ -162,18 +173,17 @@ export function isChatStreamErrorFrame(value: unknown): value is ChatStreamStrea } /** - * Returns true when both the deployment policy and the request protocol opt-in - * are present. Simple SSE checks this before emitting thinking/tool frames. + * Whether the client declared it understands agent-events-v1 framing. + * + * This is a statement about the *client*, not about what a deployment may + * expose: sending the header means the client appends `chunk` and honors + * `chunk_reset`, so answer text can stream live and be retracted. Thinking and + * tool exposure are separate deployment policies on top of this. */ -export function shouldEmitAgentStreamEvents(options: { - includeThinking: boolean | null | undefined +export function clientAcceptsAgentStreamProtocol( requestHeaders: Headers | { get(name: string): string | null } -}): boolean { - if (options.includeThinking !== true) { - return false - } - - const raw = options.requestHeaders.get(AGENT_STREAM_PROTOCOL_HEADER) +): boolean { + const raw = requestHeaders.get(AGENT_STREAM_PROTOCOL_HEADER) if (!raw) { return false } @@ -186,3 +196,24 @@ export function shouldEmitAgentStreamEvents(options: { return tokens.includes(AGENT_STREAM_PROTOCOL_V1) } + +/** + * Returns true when a negotiated client may receive thinking or tool frames — + * at least one deployment policy is on and the client accepts the protocol. + * + * Drives the run-level `agentEvents` flag, which asks providers for reasoning + * summaries. Answer-text cadence does *not* depend on this: a negotiated client + * streams live text even with both policies off. Frame emitters still apply + * each independent policy before exposing its corresponding frames. + */ +export function shouldEmitAgentStreamEvents(options: { + includeThinking: boolean | null | undefined + includeToolCalls: boolean | null | undefined + requestHeaders: Headers | { get(name: string): string | null } +}): boolean { + if (options.includeThinking !== true && options.includeToolCalls !== true) { + return false + } + + return clientAcceptsAgentStreamProtocol(options.requestHeaders) +} diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index da07914f4e9..7d1a25d4ee8 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -4,7 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readSSEStream } from '@/lib/core/utils/sse' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' -import { createStreamingResponse } from '@/lib/workflows/streaming/streaming' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' const { mockDownloadFile } = vi.hoisted(() => ({ mockDownloadFile: vi.fn(), @@ -605,6 +608,131 @@ describe('createStreamingResponse', () => { }) }) +describe('final envelope tool payloads', () => { + const agentOutput = { + content: 'Done', + toolCalls: { + count: 1, + list: [ + { + name: 'get_weather', + duration: 12, + arguments: { city: 'private' }, + result: { temperature: 72 }, + }, + ], + }, + } + + function executeFnReturning(output: Record) { + return async () => + ({ + success: true, + output, + logs: [ + { + blockId: 'agent-1', + output, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + }) as any + } + + it('redacts tool arguments and results for public chat', async () => { + // No outputConfigs means the whole block output rides the envelope, which + // must not become a side channel around the tool-frame gate. + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: true, selectedOutputs: [] }, + executeFn: executeFnReturning(agentOutput), + }) + + const events = await collectSSEEvents(stream) + const final = events.find((event) => event.event === 'final') + const toolCall = (final?.data as any).output.toolCalls.list[0] + + expect(toolCall).toEqual({ name: 'get_weather', duration: 12 }) + expect(JSON.stringify(final)).not.toContain('private') + expect(JSON.stringify(final)).not.toContain('72') + }) + + /** + * A deployment almost always selects outputs, so redaction that only covered + * the empty-selection branch would be dead in the case it exists for. + */ + it('redacts tool payloads when the deployment selects toolCalls directly', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: true, selectedOutputs: ['block_toolCalls'] }, + executeFn: async ({ onBlockComplete }) => { + const toolOnlyOutput = { toolCalls: agentOutput.toolCalls } + await onBlockComplete('block', toolOnlyOutput) + return { + success: true, + output: {}, + logs: [ + { + blockId: 'block', + output: toolOnlyOutput, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + // The payload rides the chunk frame, not `final`, so assert on the whole + // stream — sanitizing only the envelope would still leak here. + const events = await collectSSEEvents(stream) + const serialized = JSON.stringify(events) + + expect(serialized).not.toContain('private') + expect(serialized).not.toContain('72') + expect(serialized).toContain('get_weather') + }) + + it('keeps tool results for the authenticated workflow API', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { isSecureMode: false, selectedOutputs: [] }, + executeFn: executeFnReturning(agentOutput), + }) + + const events = await collectSSEEvents(stream) + const final = events.find((event) => event.event === 'final') + const toolCall = (final?.data as any).output.toolCalls.list[0] + + expect(toolCall.arguments).toEqual({ city: 'private' }) + expect(toolCall.result).toEqual({ temperature: 72 }) + }) +}) + +describe('agent stream protocol response headers', () => { + const requestHeaders = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + + it('echoes the protocol whenever the client negotiated it', () => { + // v1 framing (live text + chunk_reset) is in effect on client capability + // alone, so the echo must not depend on the event policies. + expect(agentStreamProtocolResponseHeaders({ requestHeaders })).toEqual({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + }) + + it('stays inactive for legacy clients and when no headers are supplied', () => { + expect(agentStreamProtocolResponseHeaders({ requestHeaders: new Headers() })).toEqual({}) + expect(agentStreamProtocolResponseHeaders({})).toEqual({}) + }) +}) + describe('createStreamingResponse agent-events-v1', () => { beforeEach(() => { vi.clearAllMocks() @@ -616,8 +744,14 @@ describe('createStreamingResponse agent-events-v1', () => { answer: string fail?: boolean tools?: Array< - | { type: 'tool_call_start'; id: string; name: string } - | { type: 'tool_call_end'; id: string; name: string; status: string } + | { type: 'tool_call_start'; id: string; name: string; args?: unknown } + | { + type: 'tool_call_end' + id: string + name: string + status: string + result?: unknown + } > }) { return async ({ @@ -710,22 +844,25 @@ describe('createStreamingResponse agent-events-v1', () => { .map((chunk) => chunk.slice(6)) } - it('legacy path without protocol header stays text-only (no thinking frames)', async () => { + it('legacy path without protocol header stays text-only', async () => { const stream = await createStreamingResponse({ requestId: 'request-1', streamConfig: { includeThinking: true, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, // No requestHeaders → gate closed executeFn: createAgentStreamExecuteFn({ thinking: ['secret thought'], answer: 'Hello', + tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }], }), }) const events = await collectSSEEvents(stream) expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events.some((event) => event.event === 'tool')).toBe(false) expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Hello' }) expect(events.some((event) => event.event === 'final')).toBe(true) }) @@ -739,6 +876,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ @@ -756,7 +894,7 @@ describe('createStreamingResponse agent-events-v1', () => { expect(events.some((event) => event.event === 'final')).toBe(true) }) - it('dual gate emits tool start/end frames without putting tools on chunk', async () => { + it('includeToolCalls emits tool start/end frames without exposing args or results', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -764,18 +902,25 @@ describe('createStreamingResponse agent-events-v1', () => { requestId: 'request-1', requestHeaders: headers, streamConfig: { - includeThinking: true, + includeThinking: false, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ answer: 'Done', tools: [ - { type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }, + { + type: 'tool_call_start', + id: 'toolu_1', + name: 'get_weather', + args: { city: 'private' }, + }, { type: 'tool_call_end', id: 'toolu_1', name: 'get_weather', status: 'success', + result: { temperature: 72 }, }, ], }), @@ -809,7 +954,7 @@ describe('createStreamingResponse agent-events-v1', () => { ).toBe(false) }) - it('dual gate streams pending text live and resets intermediate turns', async () => { + it('tool-only policy streams pending text live and resets intermediate turns', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -817,7 +962,8 @@ describe('createStreamingResponse agent-events-v1', () => { requestId: 'request-1', requestHeaders: headers, streamConfig: { - includeThinking: true, + includeThinking: false, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, executeFn: async ({ onStream }) => { @@ -894,6 +1040,89 @@ describe('createStreamingResponse agent-events-v1', () => { ) }) + it('streams answer text live for a negotiated client with both policies off', async () => { + // Answer cadence follows client capability, not event policy: a chat with + // thinking and tools both disabled must still stream token by token, the + // way it did before streaming tool loops existed. + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + includeToolCalls: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => {} + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'Final answer' }, + logs: [], + metadata: {}, + }, + } as any) + + await sink?.onEvent({ type: 'thinking_delta', text: 'secret reasoning' }) + await sink?.onEvent({ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }) + await sink?.onEvent({ type: 'turn_end', turn: 'intermediate' }) + await sink?.onEvent({ + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }) + await sink?.onEvent({ type: 'text_delta', text: 'Final ', turn: 'pending' }) + await sink?.onEvent({ type: 'text_delta', text: 'answer', turn: 'pending' }) + await sink?.onEvent({ type: 'turn_end', turn: 'final' }) + textController.enqueue(new TextEncoder().encode('Final answer')) + textController.close() + await onStreamPromise + + return { + success: true, + output: { content: 'Final answer' }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + + expect(events.filter((event) => event.chunk !== undefined).map((event) => event.chunk)).toEqual( + ['Final ', 'answer'] + ) + // Capability alone must not expose either gated event type. + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events.some((event) => event.event === 'tool')).toBe(false) + }) + it('dual gate keeps byte-path chunks for response-format transformed streams', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', @@ -903,6 +1132,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: async ({ onStream }) => { @@ -963,7 +1193,7 @@ describe('createStreamingResponse agent-events-v1', () => { expect(events.some((event) => event.event === 'chunk_reset')).toBe(false) }) - it('protocol header without includeThinking does not emit tool frames', async () => { + it('includeThinking without includeToolCalls does not emit tool frames', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -971,7 +1201,8 @@ describe('createStreamingResponse agent-events-v1', () => { requestId: 'request-1', requestHeaders: headers, streamConfig: { - includeThinking: false, + includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ @@ -985,7 +1216,7 @@ describe('createStreamingResponse agent-events-v1', () => { expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) }) - it('protocol header without includeThinking does not emit thinking', async () => { + it('includeToolCalls without includeThinking does not emit thinking', async () => { const headers = new Headers({ 'x-sim-stream-protocol': 'agent-events-v1', }) @@ -994,6 +1225,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: false, + includeToolCalls: true, selectedOutputs: ['agent-1_content'], }, executeFn: createAgentStreamExecuteFn({ @@ -1064,6 +1296,7 @@ describe('createStreamingResponse agent-events-v1', () => { requestHeaders: headers, streamConfig: { includeThinking: true, + includeToolCalls: false, selectedOutputs: ['agent-1_content'], }, executeFn: async ({ onStream }) => { diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index b96918bcba0..96beb1c1da9 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' +import { isRecordLike, omit } from '@sim/utils/object' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { extractBlockIdFromOutputId, @@ -35,7 +35,7 @@ import { type ChatStreamStreamErrorFrame, type ChatStreamThinkingFrame, type ChatStreamToolFrame, - shouldEmitAgentStreamEvents, + clientAcceptsAgentStreamProtocol, } from '@/lib/workflows/streaming/agent-stream-protocol' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' @@ -60,10 +60,11 @@ const SELECTED_OUTPUT_TOO_LARGE_MESSAGE = * Simple SSE stream contract — frame shapes are the `ChatStreamFrame` union in * `agent-stream-protocol.ts`, consumed by both these emitters and the chat client: * - Answer text: `{ blockId, chunk }` only (`chunk` is forever answer text). - * Legacy clients get settled final-turn text; dual-gated clients get answer - * text live from the agent-events sink, reconciled by + * Legacy clients get settled final-turn text; protocol-negotiated clients get + * answer text live from the agent-events sink, reconciled by * `{ blockId, event: 'chunk_reset' }` when a turn resolves to tool calls. * - Thinking (opt-in): `{ blockId, event: 'thinking', data }` — never uses `chunk`. + * - Tool lifecycle (opt-in): `{ blockId, event: 'tool', ... }` — name/status only. * - Success terminal: `{ event: 'final', data }` then `[DONE]`. * - Failure terminal: exactly one `{ event: 'error', ... }` then `[DONE]`. No `final` after failure. * - Mid-block read issues may emit non-terminal `{ event: 'stream_error', blockId, error }`. @@ -78,11 +79,10 @@ interface StreamingConfig { includeFileBase64?: boolean base64MaxBytes?: number timeoutMs?: number - /** - * Deployment policy for thinking/tool SSE. Still requires the client to send - * {@link AGENT_STREAM_PROTOCOL_HEADER}: {@link AGENT_STREAM_PROTOCOL_V1}. - */ + /** Thinking SSE policy; still requires the negotiated agent-events protocol. */ includeThinking?: boolean + /** Tool lifecycle SSE policy; still requires the negotiated agent-events protocol. */ + includeToolCalls?: boolean } export type StreamingExecutorFn = (callbacks: { @@ -104,26 +104,23 @@ export interface StreamingResponseOptions { userId?: string /** Incoming fetch/request abort — combined with the stream timeout. */ requestSignal?: AbortSignal - /** Used with {@link StreamingConfig.includeThinking} for dual-gate thinking SSE. */ + /** Used with the independent event policies to negotiate agent-events SSE. */ requestHeaders?: Headers | { get(name: string): string | null } executeFn: StreamingExecutorFn } /** - * Extra response headers when the dual-gate agent stream protocol is active. - * Callers should merge these into the SSE response alongside {@link SSE_HEADERS}. + * Echoes the stream protocol back when the client negotiated it, so the client + * knows v1 framing is in effect and that `chunk_reset` may arrive. Driven by + * client capability alone — a negotiated client streams live answer text even + * when both event policies are off. Merge into the SSE response alongside + * {@link SSE_HEADERS}. */ export function agentStreamProtocolResponseHeaders(options: { - includeThinking?: boolean | null requestHeaders?: Headers | { get(name: string): string | null } }): Record { if (!options.requestHeaders) return {} - if ( - !shouldEmitAgentStreamEvents({ - includeThinking: options.includeThinking, - requestHeaders: options.requestHeaders, - }) - ) { + if (!clientAcceptsAgentStreamProtocol(options.requestHeaders)) { return {} } return { [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 } @@ -252,45 +249,69 @@ function assertSelectedOutputBytes(value: unknown): number { return bytes } +/** Tool-call payload keys that must never ride a public `final` envelope. */ +const TOOL_PAYLOAD_KEYS = ['arguments', 'input', 'result', 'output'] as const + +function redactToolCallPayloads(toolCall: unknown): unknown { + if (!toolCall || typeof toolCall !== 'object') return toolCall + return omit(toolCall as Record, [...TOOL_PAYLOAD_KEYS]) +} + /** - * Strips model internals from `providerTiming.timeSegments` before an output - * rides a simple-SSE `final` envelope: `thinkingContent`, intermediate - * `assistantContent`, and tool-call arguments would otherwise reach public - * chat clients wholesale, bypassing the dual-gated thinking/tool frames. - * Timing numbers and tool names stay — they carry no model internals. + * Strips model internals from an output before it rides a simple-SSE `final` + * envelope. + * + * `thinkingContent`, intermediate `assistantContent`, and tool-call arguments + * inside `providerTiming.timeSegments` would otherwise reach clients wholesale, + * bypassing the independently gated thinking/tool frames. Timing numbers and + * tool names stay — they carry no model internals. + * + * With `redactToolPayloads` (public chat, where the caller is an anonymous end + * user rather than the workflow owner), the block's own top-level `toolCalls` + * are reduced the same way. Without it, the authenticated workflow API keeps + * returning tool results, which callers legitimately consume. */ -function sanitizeProviderTimingForEnvelope( - output: Record +function sanitizeOutputForEnvelope( + output: Record, + options: { redactToolPayloads: boolean } ): Record { + let sanitized = output + const providerTiming = output.providerTiming as { timeSegments?: unknown } | undefined - if (!providerTiming || !Array.isArray(providerTiming.timeSegments)) { - return output + if (providerTiming && Array.isArray(providerTiming.timeSegments)) { + sanitized = { + ...sanitized, + providerTiming: { + ...providerTiming, + timeSegments: providerTiming.timeSegments.map((segment) => { + if (!segment || typeof segment !== 'object') return segment + const { toolCalls, ...rest } = omit(segment as Record, [ + 'thinkingContent', + 'assistantContent', + ]) as Record & { toolCalls?: unknown } + return { + ...rest, + ...(Array.isArray(toolCalls) + ? { toolCalls: toolCalls.map(redactToolCallPayloads) } + : {}), + } + }), + }, + } } - return { - ...output, - providerTiming: { - ...providerTiming, - timeSegments: providerTiming.timeSegments.map((segment) => { - if (!segment || typeof segment !== 'object') return segment - const { toolCalls, ...rest } = omit(segment as Record, [ - 'thinkingContent', - 'assistantContent', - ]) as Record & { toolCalls?: unknown } - return { - ...rest, - ...(Array.isArray(toolCalls) - ? { - toolCalls: toolCalls.map((toolCall) => - toolCall && typeof toolCall === 'object' - ? omit(toolCall as Record, ['arguments']) - : toolCall - ), - } - : {}), - } - }), - }, + + const blockToolCalls = sanitized.toolCalls as { list?: unknown } | undefined + if (options.redactToolPayloads && blockToolCalls && Array.isArray(blockToolCalls.list)) { + sanitized = { + ...sanitized, + toolCalls: { + ...blockToolCalls, + list: blockToolCalls.list.map(redactToolCallPayloads), + }, + } } + + return sanitized } async function buildMinimalResult( @@ -303,8 +324,12 @@ async function buildMinimalResult( includeFileBase64: boolean, base64MaxBytes: number | undefined, executionId?: string, - context: Omit = { requestId } + context: Omit & { + /** Public chat: reduce the block's own tool calls to name + lifecycle. */ + redactToolPayloads?: boolean + } = { requestId } ): Promise<{ success: boolean; error?: string; output: Record }> { + const envelopeOptions = { redactToolPayloads: context.redactToolPayloads === true } const durableContext = { workspaceId: context.workspaceId, workflowId: context.workflowId, @@ -320,7 +345,7 @@ async function buildMinimalResult( } if (result.status === 'paused') { - minimalResult.output = sanitizeProviderTimingForEnvelope(result.output || {}) + minimalResult.output = sanitizeOutputForEnvelope(result.output || {}, envelopeOptions) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -329,7 +354,7 @@ async function buildMinimalResult( } if (!selectedOutputs?.length) { - minimalResult.output = sanitizeProviderTimingForEnvelope(result.output || {}) + minimalResult.output = sanitizeOutputForEnvelope(result.output || {}, envelopeOptions) return compactExecutionPayload(minimalResult, { ...durableContext, preserveUserFileBase64: includeFileBase64, @@ -341,6 +366,23 @@ async function buildMinimalResult( return minimalResult } + /** + * Selected outputs are extracted from the sanitized block output, not the raw + * log. A deployment can select `toolCalls` or `providerTiming` directly, so + * sanitizing per selected path would leave the leak open for whichever path + * was missed; sanitizing the source closes it for every path at once. Cached + * per block because several descriptors can target the same one. + */ + const sanitizedBlockOutputs = new Map>() + const sanitizedOutputFor = (blockId: string, output: Record) => { + const cached = sanitizedBlockOutputs.get(blockId) + if (cached) return cached + + const sanitized = sanitizeOutputForEnvelope(output, envelopeOptions) + sanitizedBlockOutputs.set(blockId, sanitized) + return sanitized + } + let selectedOutputBytes = assertSelectedOutputBytes(minimalResult.output) for (const descriptor of getSelectedOutputDescriptors(selectedOutputs)) { const { blockId, path } = descriptor @@ -381,7 +423,11 @@ async function buildMinimalResult( getBase64DecodedByteBudget(remainingBytes) ), } - const value = await extractOutputValue(blockLog.output, path, extractionContext) + const value = await extractOutputValue( + sanitizedOutputFor(blockId, blockLog.output), + path, + extractionContext + ) if (value === undefined) { continue } @@ -452,12 +498,15 @@ export async function createStreamingResponse( ): Promise { const { requestId, streamConfig, executionId, executeFn } = options const timeoutController = createTimeoutAbortController(streamConfig.timeoutMs) - const emitAgentEvents = - Boolean(options.requestHeaders) && - shouldEmitAgentStreamEvents({ - includeThinking: streamConfig.includeThinking, - requestHeaders: options.requestHeaders!, - }) + /** + * Client capability, not deployment policy: a negotiated client can render + * live answer text and honor `chunk_reset`, so it streams token by token + * regardless of whether thinking or tools are exposed. + */ + const clientAcceptsProtocol = + Boolean(options.requestHeaders) && clientAcceptsAgentStreamProtocol(options.requestHeaders!) + const emitThinking = clientAcceptsProtocol && streamConfig.includeThinking === true + const emitToolCalls = clientAcceptsProtocol && streamConfig.includeToolCalls === true const maxThinkingChars = DEFAULT_MAX_THINKING_CHARS let requestAborted = false @@ -554,14 +603,19 @@ export async function createStreamingResponse( } /** - * Dual-gated clients get answer text live from the sink (pending deltas + * Negotiated clients get answer text live from the sink (pending deltas * stream as the model generates; `chunk_reset` clears an intermediate * turn). The byte stream then only feeds `streamedChunks` for logs. + * + * Legacy clients stay on the byte stream, which a streaming tool loop + * only writes once the turn is classified — correct for a consumer that + * cannot retract, at the cost of arriving in one piece. + * * Response-format projections rewrite the bytes, so those blocks keep - * the byte stream as the frame source. + * the byte stream as the frame source either way. */ const sinkAnswerText = - emitAgentEvents && + clientAcceptsProtocol && Boolean(streamingExec.subscribe) && streamingExec.clientStreamTransformed !== true @@ -581,15 +635,15 @@ export async function createStreamingResponse( } let unsubscribe: (() => void) | undefined - if (emitAgentEvents && streamingExec.subscribe) { + if (clientAcceptsProtocol && streamingExec.subscribe) { unsubscribe = streamingExec.subscribe({ onEvent: async (event) => { if (event.type === 'thinking_delta') { - sendThinking(blockId, event.text) + if (emitThinking) sendThinking(blockId, event.text) } else if (event.type === 'tool_call_start') { - sendTool(blockId, 'start', event.id, event.name) + if (emitToolCalls) sendTool(blockId, 'start', event.id, event.name) } else if (event.type === 'tool_call_end') { - sendTool(blockId, 'end', event.id, event.name, event.status) + if (emitToolCalls) sendTool(blockId, 'end', event.id, event.name, event.status) } else if (sinkAnswerText && event.type === 'text_delta') { if (event.turn !== 'intermediate') { emitAnswerChunk(event.text) @@ -659,6 +713,18 @@ export async function createStreamingResponse( (descriptor) => descriptor.blockId === blockId ) + /** + * A selected output is streamed here and then skipped in the `final` + * envelope, so this is the reachable path for a deployment that selects + * `toolCalls` or `providerTiming` — sanitizing only the envelope would + * leave the payload flowing through the chunk frame instead. + */ + const sanitizedOutput = isRecordLike(output) + ? sanitizeOutputForEnvelope(output, { + redactToolPayloads: streamConfig.isSecureMode === true, + }) + : output + for (const descriptor of matchingOutputs) { if (state.selectedOutputError) { break @@ -681,7 +747,11 @@ export async function createStreamingResponse( ), } const materializationContext = buildMaterializationContext(extractionContext) - const outputValue = await extractOutputValue(output, descriptor.path, extractionContext) + const outputValue = await extractOutputValue( + sanitizedOutput, + descriptor.path, + extractionContext + ) if (outputValue !== undefined) { const materializedOutput = await materializeInlineExecutionValue( @@ -802,6 +872,7 @@ export async function createStreamingResponse( fileKeys: result.metadata?.fileKeys ?? options.fileKeys, allowLargeValueWorkflowScope: options.allowLargeValueWorkflowScope, userId: options.userId, + redactToolPayloads: streamConfig.isSecureMode === true, } ) diff --git a/apps/sim/providers/anthropic/core.request.test.ts b/apps/sim/providers/anthropic/core.request.test.ts new file mode 100644 index 00000000000..afe13ce5ff8 --- /dev/null +++ b/apps/sim/providers/anthropic/core.request.test.ts @@ -0,0 +1,250 @@ +/** + * @vitest-environment node + */ +import type Anthropic from '@anthropic-ai/sdk' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +describe('executeAnthropicProviderRequest request identity and usage', () => { + beforeEach(() => { + mockExecuteTool.mockReset() + }) + + it('keeps registry identity while sending the resolved wire model and aggregating cache usage', async () => { + const create = vi.fn().mockResolvedValue({ + id: 'msg-test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + cache_creation: null, + output_tokens: 40, + }, + }) + + const result = (await executeAnthropicProviderRequest( + { + model: 'azure-anthropic/claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + messages: [{ role: 'system', content: 'Remain concise.' }], + }, + { + providerId: 'azure-anthropic', + providerLabel: 'Azure Anthropic', + resolveWireModel: () => 'claude-sonnet-4-5', + createClient: () => ({ messages: { create } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + )) as ProviderResponse + + expect(create.mock.calls[0][0]).toMatchObject({ + model: 'claude-sonnet-4-5', + // System is always block-shaped so cache_control has somewhere to live. + system: [{ type: 'text', text: 'Remain concise.' }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], + }) + expect(result.model).toBe('azure-anthropic/claude-sonnet-4-5') + expect(result.tokens).toEqual({ + input: 10, + output: 40, + total: 100, + cacheRead: 20, + cacheWrite: 30, + }) + expect(result.cost).toMatchObject({ + input: 0.0001485, + output: 0.0006, + total: 0.0007485, + }) + expect(result.timing?.timeSegments?.[0]).toMatchObject({ + provider: 'azure-anthropic', + tokens: { + input: 10, + output: 40, + total: 100, + cacheRead: 20, + cacheWrite: 30, + }, + }) + }) + + it('applies tool post-processing consistently in non-streaming tool loops', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { posted: true } }) + const create = vi + .fn() + .mockResolvedValueOnce({ + id: 'msg-tool', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'tool_use', id: 'tool-1', name: 'publish', input: {} }], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + .mockResolvedValueOnce({ + id: 'msg-answer', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Published' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + + await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + messages: [{ role: 'user', content: 'Publish this' }], + tools: [ + { + id: 'publish', + name: 'publish', + description: 'Publish a post', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { create } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'publish', + expect.any(Object), + expect.not.objectContaining({ skipPostProcess: true }) + ) + }) +}) + +describe('executeAnthropicProviderRequest prompt caching', () => { + function answerOnce() { + return vi.fn().mockResolvedValue({ + id: 'msg-cache', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'Done' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + }) + } + + const tools = [ + { + id: 'first', + name: 'first', + description: 'First tool', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + { + id: 'second', + name: 'second', + description: 'Second tool', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ] + + async function sendRequest(overrides: Partial) { + const create = answerOnce() + await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + maxTokens: 1024, + systemPrompt: 'Remain concise.', + messages: [{ role: 'user', content: 'Hello' }], + ...overrides, + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { create } }) as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } + ) + return create.mock.calls[0][0] as Anthropic.Messages.MessageCreateParams + } + + beforeEach(() => { + mockExecuteTool.mockReset() + }) + + it('breaks the cache after the last tool and the last system block when enabled', async () => { + const payload = await sendRequest({ promptCaching: true, tools }) + + expect(payload.system).toEqual([ + { type: 'text', text: 'Remain concise.', cache_control: { type: 'ephemeral' } }, + ]) + expect(payload.tools?.map((tool) => tool.cache_control)).toEqual([ + undefined, + { type: 'ephemeral' }, + ]) + }) + + it('sends no breakpoints when caching is off', async () => { + const payload = await sendRequest({ tools }) + + expect(payload.system).toEqual([{ type: 'text', text: 'Remain concise.' }]) + expect(payload.tools?.some((tool) => tool.cache_control)).toBe(false) + }) + + it('appends schema instructions as a block and caches only the last one', async () => { + const payload = await sendRequest({ + // Opus 4.1 lacks native structured outputs, so the schema is injected + // into the system prompt — the path that used to string-concatenate. + model: 'claude-opus-4-1', + promptCaching: true, + responseFormat: { name: 'answer', schema: { type: 'object', properties: {} } }, + }) + + const blocks = payload.system as Anthropic.Messages.TextBlockParam[] + expect(blocks).toHaveLength(2) + expect(blocks[0]).toEqual({ type: 'text', text: 'Remain concise.' }) + expect(blocks[1].text).toContain('answer') + expect(blocks[1].cache_control).toEqual({ type: 'ephemeral' }) + }) + + it('omits the system field entirely when there is no system text', async () => { + const payload = await sendRequest({ promptCaching: true, systemPrompt: undefined }) + + expect(payload.system).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/anthropic/core.streaming.test.ts b/apps/sim/providers/anthropic/core.streaming.test.ts new file mode 100644 index 00000000000..2424f53be66 --- /dev/null +++ b/apps/sim/providers/anthropic/core.streaming.test.ts @@ -0,0 +1,220 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeAnthropicProviderRequest } from '@/providers/anthropic/core' +import type { AgentStreamEvent } from '@/providers/stream-events' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +function message(content: unknown[], stopReason: string) { + return { + id: `msg-${stopReason}`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { input_tokens: 2, output_tokens: 2 }, + } +} + +function stream(events: unknown[], finalMessage: ReturnType) { + return { + async *[Symbol.asyncIterator]() { + yield* events + }, + finalMessage: async () => finalMessage, + } +} + +async function collectEvents(result: StreamingExecution): Promise { + const events: AgentStreamEvent[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + +describe('executeAnthropicProviderRequest live tool streaming', () => { + it.each([ + ['anthropic', 'Anthropic'], + ['azure-anthropic', 'Azure Anthropic'], + ] as const)( + 'uses the live loop for %s streaming tool requests without a caller flag', + async (providerId, providerLabel) => { + const model = + providerId === 'azure-anthropic' ? 'azure-anthropic/claude-sonnet-4-5' : 'claude-sonnet-4-5' + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const toolMessage = message( + [{ type: 'tool_use', id: 'tool-1', name: 'lookup', input: {} }], + 'tool_use' + ) + const answerMessage = message([{ type: 'text', text: 'settled answer' }], 'end_turn') + const createStream = vi + .fn() + .mockReturnValueOnce( + stream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'tool-1', + name: 'lookup', + input: {}, + }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_stop' }, + ], + toolMessage + ) + ) + .mockReturnValueOnce( + stream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'settled answer' }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_stop' }, + ], + answerMessage + ) + ) + + const result = (await executeAnthropicProviderRequest( + { + model, + apiKey: 'test-key', + stream: true, + maxTokens: 1024, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId, + providerLabel, + ...(providerId === 'azure-anthropic' + ? { resolveWireModel: () => 'claude-sonnet-4-5' } + : {}), + createClient: () => ({ messages: { stream: createStream } }) as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + } + )) as StreamingExecution + + const events = await collectEvents(result) + + expect(createStream).toHaveBeenCalledTimes(2) + expect(createStream.mock.calls[0][0].model).toBe('claude-sonnet-4-5') + expect(events).toContainEqual({ + type: 'text_delta', + text: 'settled answer', + turn: 'pending', + }) + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(result.execution.output.content).toBe('settled answer') + expect(result.execution.output.model).toBe(model) + expect( + result.execution.output.providerTiming?.timeSegments + ?.filter((segment) => segment.type === 'model') + .map((segment) => segment.provider) + ).toEqual([providerId, providerId]) + } + ) +}) + +/** + * Streaming is the common path, and breakpoints are placed on the shared + * payload before the loop is handed it. A regression that moved placement + * below the streaming branch would silently stop caching while the + * non-streaming payload tests still passed. + */ +describe('executeAnthropicProviderRequest streaming prompt caching', () => { + it('carries cache breakpoints into every turn of the live tool loop', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const createStream = vi + .fn() + .mockReturnValueOnce( + stream( + [{ type: 'message_stop' }], + message([{ type: 'tool_use', id: 'tool-1', name: 'lookup', input: {} }], 'tool_use') + ) + ) + .mockReturnValueOnce( + stream([{ type: 'message_stop' }], message([{ type: 'text', text: 'done' }], 'end_turn')) + ) + + const result = (await executeAnthropicProviderRequest( + { + model: 'claude-sonnet-4-5', + apiKey: 'test-key', + stream: true, + maxTokens: 1024, + promptCaching: true, + systemPrompt: 'Remain concise.', + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + { + providerId: 'anthropic', + providerLabel: 'Anthropic', + createClient: () => ({ messages: { stream: createStream } }) as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + } + )) as StreamingExecution + + await collectEvents(result) + + expect(createStream).toHaveBeenCalledTimes(2) + for (const call of createStream.mock.calls) { + const payload = call[0] + expect(payload.system).toEqual([ + { type: 'text', text: 'Remain concise.', cache_control: { type: 'ephemeral' } }, + ]) + expect(payload.tools.at(-1).cache_control).toEqual({ type: 'ephemeral' }) + } + }) +}) diff --git a/apps/sim/providers/anthropic/core.thinking.test.ts b/apps/sim/providers/anthropic/core.thinking.test.ts index 1213fcf8751..8fcad2e438d 100644 --- a/apps/sim/providers/anthropic/core.thinking.test.ts +++ b/apps/sim/providers/anthropic/core.thinking.test.ts @@ -37,10 +37,10 @@ describe('buildThinkingConfig', () => { } }) - it('never adds display for adaptive models that already stream full thinking', () => { + it('requests summarized display for adaptive models marked as summary-streamed', () => { for (const model of ['claude-opus-4-6', 'claude-sonnet-4-6']) { const config = buildThinkingConfig(model, 'high', true) - expect(config?.thinking).toEqual({ type: 'adaptive' }) + expect(config?.thinking).toEqual({ type: 'adaptive', display: 'summarized' }) } }) diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index bdd690efe1d..88e5d8d71a6 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,19 +3,21 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { - BlockTokens, - IterationToolCall, - NormalizedBlockOutput, - StreamingExecution, -} from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { convertAnthropicRequestHistory } from '@/providers/anthropic/request-history' import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' import { checkForForcedToolUsage, createReadableStreamFromAnthropicStream, } from '@/providers/anthropic/utils' -import { buildAnthropicMessageContent } from '@/providers/attachments' import { getMaxOutputTokensForModel, getThinkingCapability, @@ -23,16 +25,12 @@ import { supportsTemperature, } from '@/providers/models' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError } from '@/providers/streaming-tool-loop-shared' import { adaptAnthropicToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' -import { - calculateCost, - prepareToolExecution, - prepareToolsWithUsageControl, - sumToolCosts, -} from '@/providers/utils' +import { prepareToolExecution, prepareToolsWithUsageControl } from '@/providers/utils' import { executeTool } from '@/tools' /** @@ -44,19 +42,53 @@ export interface AnthropicProviderConfig { /** Human-readable label for logging */ providerLabel: string /** Factory function to create the Anthropic client */ - createClient: (apiKey: string, useNativeStructuredOutputs: boolean) => Anthropic + createClient: (apiKey: string) => Anthropic + /** Resolves the provider-specific model or deployment name sent on the wire. */ + resolveWireModel?: (request: ProviderRequest) => string /** Logger instance */ logger: Logger } +type AnthropicPayload = Anthropic.Messages.MessageStreamParams + +/** Anthropic's only cache type; the default 5-minute TTL suits agent reuse. */ +const EPHEMERAL_CACHE: Anthropic.Messages.CacheControlEphemeral = { type: 'ephemeral' } + /** - * Custom payload type extending the SDK's base message creation params. - * Message params plus `output_format`: Sim's structured outputs ride the - * anthropic-beta header with a top-level `output_format` field, which the SDK - * does not model (it exposes the newer `output_config.format` shape instead). + * Builds the `system` field as text blocks. + * + * Always an array, never a bare string, so the shape does not depend on whether + * prompt caching is on and `cache_control` always has a block to attach to. + * Returns `undefined` when there is no system text so the field is omitted. */ -interface AnthropicPayload extends Anthropic.Messages.MessageStreamParams { - output_format?: { type: 'json_schema'; schema: Record } +function buildSystemBlocks( + texts: string[], + cacheLastBlock: boolean +): Anthropic.Messages.TextBlockParam[] | undefined { + const blocks: Anthropic.Messages.TextBlockParam[] = texts + .filter((text) => text.trim().length > 0) + .map((text) => ({ type: 'text', text })) + + if (blocks.length === 0) return undefined + + if (cacheLastBlock) { + blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: EPHEMERAL_CACHE } + } + return blocks +} + +/** + * Marks the final tool definition, caching every tool ahead of it. + * + * Typed on `ToolUnion` to match the payload field: every member of that union, + * including the server-side tools, accepts `cache_control`. + */ +function withCacheBreakpointOnLast( + tools: Anthropic.Messages.ToolUnion[] +): Anthropic.Messages.ToolUnion[] { + const marked = [...tools] + marked[marked.length - 1] = { ...marked[marked.length - 1], cache_control: EPHEMERAL_CACHE } + return marked } /** @@ -224,61 +256,22 @@ export async function executeAnthropicProviderRequest( request.responseFormat && supportsNativeStructuredOutputs(modelId) ) - const anthropic = config.createClient(request.apiKey, useNativeStructuredOutputs) - - const messages: Anthropic.Messages.MessageParam[] = [] - let systemPrompt = request.systemPrompt || '' - - if (request.context) { - messages.push({ - role: 'user', - content: request.context, - }) - } - - if (request.messages) { - request.messages.forEach((msg) => { - if (msg.role === 'function') { - messages.push({ - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: msg.name || '', - content: msg.content || undefined, - }, - ], - }) - } else if (msg.function_call) { - const toolUseId = `${msg.function_call.name}-${Date.now()}` - messages.push({ - role: 'assistant', - content: [ - { - type: 'tool_use', - id: toolUseId, - name: msg.function_call.name, - input: JSON.parse(msg.function_call.arguments), - }, - ], - }) - } else { - const content = buildAnthropicMessageContent(msg.content, msg.files, config.providerId) - messages.push({ - role: msg.role === 'assistant' ? 'assistant' : 'user', - // double-cast-allowed: shared attachment builder returns Anthropic-compatible content blocks but avoids importing SDK-only union types - content: content as unknown as Anthropic.Messages.ContentBlockParam[], - }) - } - }) - } + const anthropic = config.createClient(request.apiKey) + const wireModel = config.resolveWireModel?.(request) ?? modelId + const convertedHistory = convertAnthropicRequestHistory({ + messages: request.messages, + systemPrompt: request.systemPrompt, + context: request.context, + providerId, + }) + const messages = convertedHistory.messages + const systemPrompt = convertedHistory.systemPrompt if (messages.length === 0) { messages.push({ role: 'user', - content: [{ type: 'text', text: systemPrompt || 'Hello' }], + content: [{ type: 'text', text: 'Hello' }], }) - systemPrompt = '' } let anthropicTools: Anthropic.Messages.Tool[] | undefined = request.tools?.length @@ -289,44 +282,41 @@ export async function executeAnthropicProviderRequest( let preparedTools: ReturnType | null = null if (anthropicTools?.length) { - try { - preparedTools = prepareToolsWithUsageControl( - anthropicTools, - request.tools, - logger, - providerId - ) - const { tools: filteredTools, toolChoice: tc } = preparedTools - - if (filteredTools?.length) { - anthropicTools = filteredTools - - if (typeof tc === 'object' && tc !== null) { - if (tc.type === 'tool') { - toolChoice = tc - logger.info(`Using ${providerLabel} tool_choice format: force tool "${tc.name}"`) - } else { - toolChoice = 'auto' - logger.warn(`Received non-${providerLabel} tool_choice format, defaulting to auto`) - } - } else if (tc === 'auto' || tc === 'none') { - toolChoice = tc - logger.info(`Using tool_choice mode: ${tc}`) - } else { - toolChoice = 'auto' - logger.warn('Unexpected tool_choice format, defaulting to auto') - } + preparedTools = prepareToolsWithUsageControl(anthropicTools, request.tools, logger, providerId) + const { tools: filteredTools, toolChoice: preparedToolChoice } = preparedTools + anthropicTools = filteredTools?.length + ? (filteredTools as Anthropic.Messages.Tool[]) + : undefined + + if (anthropicTools?.length) { + if (preparedToolChoice === 'auto' || preparedToolChoice === 'none') { + toolChoice = preparedToolChoice + logger.info(`Using tool_choice mode: ${preparedToolChoice}`) + } else if ( + preparedToolChoice?.type === 'tool' && + typeof preparedToolChoice.name === 'string' && + preparedToolChoice.name.length > 0 + ) { + toolChoice = preparedToolChoice + logger.info( + `Using ${providerLabel} tool_choice format: force tool "${preparedToolChoice.name}"` + ) + } else { + throw new Error(`Invalid ${providerLabel} tool choice returned by tool preparation`) } - } catch (error) { - logger.error('Error in prepareToolsWithUsageControl:', { error }) - toolChoice = 'auto' } } + /** + * System text accumulates here and is turned into blocks once, below, after + * any schema instructions are appended. Keeping it as plain strings until + * then means only one place knows the wire shape. + */ + const systemTexts = systemPrompt ? [systemPrompt] : [] + const payload: AnthropicPayload = { - model: request.model, + model: wireModel, messages, - system: systemPrompt, max_tokens: Number.parseInt(String(request.maxTokens)) || getMaxOutputTokensForModel(request.model), ...(supportsTemperature(request.model) && { @@ -339,16 +329,16 @@ export async function executeAnthropicProviderRequest( if (useNativeStructuredOutputs) { const transformedSchema = transformJSONSchema(schema) - payload.output_format = { - type: 'json_schema', - schema: transformedSchema, + payload.output_config = { + ...payload.output_config, + format: { + type: 'json_schema', + schema: transformedSchema, + }, } logger.info(`Using native structured outputs for model: ${modelId}`) } else { - const schemaInstructions = generateSchemaInstructions(schema, request.responseFormat.name) - payload.system = payload.system - ? `${payload.system}\n\n${schemaInstructions}` - : schemaInstructions + systemTexts.push(generateSchemaInstructions(schema, request.responseFormat.name)) logger.info(`Using prompt-based structured outputs for model: ${modelId}`) } } @@ -364,7 +354,10 @@ export async function executeAnthropicProviderRequest( if (thinkingConfig) { payload.thinking = thinkingConfig.thinking if (thinkingConfig.outputConfig) { - payload.output_config = thinkingConfig.outputConfig + payload.output_config = { + ...payload.output_config, + ...thinkingConfig.outputConfig, + } } // Keep budget_tokens < max_tokens (see constants above) by shrinking the budget @@ -424,9 +417,21 @@ export async function executeAnthropicProviderRequest( } } - const shouldStreamToolCalls = request.streamToolCalls ?? false + /** + * Prompt-cache breakpoints go on the static prefix, once, after tools and + * system text are final. Anthropic hashes the prefix in tools → system → + * messages order, so marking the last tool and the last system block caches + * everything ahead of the conversation, which is the part that stays byte + * stable across turns. Both tool loops spread this payload, so placing them + * here covers the streaming and non-streaming paths alike. + */ + const cacheStaticPrefix = request.promptCaching === true + if (cacheStaticPrefix && payload.tools?.length) { + payload.tools = withCacheBreakpointOnLast(payload.tools) + } + payload.system = buildSystemBlocks(systemTexts, cacheStaticPrefix) - if (request.stream && shouldStreamToolCalls && anthropicTools && anthropicTools.length > 0) { + if (request.stream && anthropicTools && anthropicTools.length > 0) { logger.info(`Using streaming tool loop for ${providerLabel} request`) const providerStartTime = Date.now() @@ -456,6 +461,7 @@ export async function executeAnthropicProviderRequest( payload, request, messages, + providerId, logger, timeSegments, forcedTools, @@ -503,23 +509,22 @@ export async function executeAnthropicProviderRequest( createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, ({ content, usage, thinking }) => { + const tokens = buildAnthropicUsageTokens(usage) + const cost = buildAnthropicUsageCost(request.model, usage) output.content = content - output.tokens = { - input: usage.input_tokens, - output: usage.output_tokens, - total: usage.input_tokens + usage.output_tokens, - } - - const costResult = calculateCost(request.model, usage.input_tokens, usage.output_tokens) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } - - if (thinking) { - const segment = output.providerTiming?.timeSegments?.[0] - if (segment) { + output.tokens = tokens + output.cost = cost + + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.provider = providerId + segment.tokens = tokens + segment.cost = { + input: cost.input, + output: cost.output, + total: cost.total, + } + if (thinking) { segment.thinkingContent = thinking } } @@ -532,430 +537,6 @@ export async function executeAnthropicProviderRequest( return streamingResult } - if (request.stream && !shouldStreamToolCalls) { - logger.info( - `Using non-streaming mode for ${providerLabel} request (tool calls executed silently)` - ) - - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - try { - const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - let currentResponse = await createMessage(anthropic, payload, request.abortSignal) - const firstResponseTime = Date.now() - initialCallTime - - let content = '' - - if (Array.isArray(currentResponse.content)) { - content = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - } - - const tokens = { - input: currentResponse.usage?.input_tokens || 0, - output: currentResponse.usage?.output_tokens || 0, - total: - (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), - } - - const toolCalls = [] - const toolResults: Record[] = [] - 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, - }, - ] - - const firstCheckResult = checkForForcedToolUsage( - currentResponse, - originalToolChoice, - forcedTools, - usedForcedTools - ) - if (firstCheckResult) { - hasUsedForcedTool = firstCheckResult.hasUsedForcedTool - usedForcedTools = firstCheckResult.usedForcedTools - } - - try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - const textContent = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - - if (textContent) { - content = textContent - } - - const toolUses = currentResponse.content.filter((item) => item.type === 'tool_use') - - enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { - model: request.model, - }) - - if (!toolUses || toolUses.length === 0) { - break - } - - const toolsStartTime = Date.now() - - const toolExecutionPromises = toolUses.map(async (toolUse) => { - const toolCallStartTime = Date.now() - const toolName = toolUse.name - const toolArgs = toolUse.input as Record - - 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 { - toolUse, - 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 { - toolUse, - 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) - - // Collect all tool_use and tool_result blocks for batching - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] - const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - - const { - toolUse, - toolName, - toolArgs, - toolParams, - result, - startTime, - endTime, - duration, - } = settledResult.value - - timeSegments.push({ - type: 'tool', - name: toolName, - startTime: startTime, - endTime: endTime, - duration: duration, - toolCallId: toolUse.id, - }) - - let resultContent: unknown - 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, - }) - - // Add to batched arrays using the ORIGINAL ID from Claude's response - toolUseBlocks.push({ - type: 'tool_use', - id: toolUse.id, - name: toolName, - input: toolArgs, - }) - - toolResultBlocks.push({ - type: 'tool_result', - tool_use_id: toolUse.id, - content: JSON.stringify(resultContent), - }) - } - - // Per Anthropic docs: thinking blocks must be preserved in assistant messages - // during tool use to maintain reasoning continuity. - const thinkingBlocks = currentResponse.content.filter( - ( - item - ): item is - | Anthropic.Messages.ThinkingBlock - | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' - ) - - // Add ONE assistant message with thinking + tool_use blocks - if (toolUseBlocks.length > 0) { - currentMessages.push({ - role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], - }) - } - - // Add ONE user message with ALL tool_result blocks - if (toolResultBlocks.length > 0) { - currentMessages.push({ - role: 'user', - content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], - }) - } - - const thisToolsTime = Date.now() - toolsStartTime - toolsTime += thisToolsTime - - const nextPayload: AnthropicPayload = { - ...payload, - messages: currentMessages, - } - - // Per Anthropic docs: forced tool_choice is incompatible with thinking. - // Only auto and none are supported when thinking is enabled. - const thinkingEnabled = !!payload.thinking - if ( - !thinkingEnabled && - typeof originalToolChoice === 'object' && - hasUsedForcedTool && - forcedTools.length > 0 - ) { - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - nextPayload.tool_choice = { - type: 'tool', - name: remainingTools[0], - } - logger.info(`Forcing next tool: ${remainingTools[0]}`) - } else { - nextPayload.tool_choice = undefined - logger.info('All forced tools have been used, removing tool_choice parameter') - } - } else if ( - !thinkingEnabled && - hasUsedForcedTool && - typeof originalToolChoice === 'object' - ) { - nextPayload.tool_choice = undefined - logger.info( - 'Removing tool_choice parameter for subsequent requests after forced tool was used' - ) - } - - const nextModelStartTime = Date.now() - - currentResponse = await createMessage(anthropic, nextPayload, request.abortSignal) - - const nextCheckResult = checkForForcedToolUsage( - currentResponse, - nextPayload.tool_choice, - 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, - }) - - modelTime += thisModelTime - - if (currentResponse.usage) { - tokens.input += currentResponse.usage.input_tokens || 0 - tokens.output += currentResponse.usage.output_tokens || 0 - tokens.total += - (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) - } - - iterationCount++ - } - - if (iterationCount === MAX_TOOL_ITERATIONS) { - const trailingText = currentResponse.content - .filter((item) => item.type === 'text') - .map((item) => item.text) - .join('\n') - enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { - model: request.model, - }) - } - } catch (error) { - logger.error(`Error in ${providerLabel} request:`, { error }) - throw error - } - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_use is never executed on this path. `tools` must stay - * (history contains tool_use blocks) but tool choice is pinned to none; - * with tools present and no choice, `auto` would let the model re-call. - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - stream: true, - tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined, - } - - const streamResponse = await anthropic.messages.create( - streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming, - 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, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, - toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - isStreaming: true, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - ({ content: streamContent, usage, thinking }) => { - if (!streamContent && content) { - logger.warn( - `${providerLabel} final stream produced no text; keeping tool-loop answer` - ) - } - output.content = streamContent || content - output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.input_tokens, - usage.output_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, - } - - if (thinking) { - const segments = output.providerTiming?.timeSegments - const lastModel = segments - ? [...segments].reverse().find((segment) => segment.type === 'model') - : undefined - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - - finalizeTiming() - } - ), - }) - - return streamingResult - } catch (error) { - const providerEndTime = Date.now() - const providerEndTimeISO = new Date(providerEndTime).toISOString() - const totalDuration = providerEndTime - providerStartTime - - logger.error(`Error in ${providerLabel} request:`, { - error, - duration: totalDuration, - }) - - throw new ProviderError(toError(error).message, { - startTime: providerStartTimeISO, - endTime: providerEndTimeISO, - duration: totalDuration, - }) - } - } - const providerStartTime = Date.now() const providerStartTimeISO = new Date(providerStartTime).toISOString() @@ -977,23 +558,8 @@ export async function executeAnthropicProviderRequest( .join('\n') } - const tokens = { - input: currentResponse.usage?.input_tokens || 0, - output: currentResponse.usage?.output_tokens || 0, - total: - (currentResponse.usage?.input_tokens || 0) + (currentResponse.usage?.output_tokens || 0), - } - - const initialCost = calculateCost( - request.model, - currentResponse.usage?.input_tokens || 0, - currentResponse.usage?.output_tokens || 0 - ) - const cost = { - input: initialCost.input, - output: initialCost.output, - total: initialCost.total, - } + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, currentResponse.usage) const toolCalls = [] const toolResults: Record[] = [] @@ -1039,9 +605,17 @@ export async function executeAnthropicProviderRequest( enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, textContent, { model: request.model, + providerId, }) - if (!toolUses || toolUses.length === 0) { + if (toolUses.length > 0 && currentResponse.stop_reason !== 'tool_use') { + throw new Error( + `${providerLabel} returned tool use with stop_reason ${ + currentResponse.stop_reason ?? 'missing' + }` + ) + } + if (toolUses.length === 0) { break } @@ -1050,17 +624,36 @@ export async function executeAnthropicProviderRequest( const toolExecutionPromises = toolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name - const toolArgs = toolUse.input as Record + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined // Preserve the original tool_use ID from Claude's response const toolUseId = toolUse.id try { + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolUseId, + toolName, + toolArgs, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { - skipPostProcess: true, signal: request.abortSignal, }) const toolCallEndTime = Date.now() @@ -1076,13 +669,16 @@ export async function executeAnthropicProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) return { toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams: {}, result: { success: false, @@ -1096,15 +692,12 @@ export async function executeAnthropicProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) // Collect all tool_use and tool_result blocks for batching - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolUseId, toolName, @@ -1114,7 +707,7 @@ export async function executeAnthropicProviderRequest( startTime, endTime, duration, - } = settledResult.value + } = executionResult timeSegments.push({ type: 'tool', @@ -1126,9 +719,11 @@ export async function executeAnthropicProviderRequest( }) let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -1147,38 +742,32 @@ export async function executeAnthropicProviderRequest( success: result.success, }) - // Add to batched arrays using the ORIGINAL ID from Claude's response - toolUseBlocks.push({ - type: 'tool_use', - id: toolUseId, - name: toolName, - input: toolArgs, - }) - toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUseId, content: JSON.stringify(resultContent), + is_error: !result.success, }) } - // Per Anthropic docs: thinking blocks must be preserved in assistant messages - // during tool use to maintain reasoning continuity. - const thinkingBlocks = currentResponse.content.filter( + const assistantBlocks = currentResponse.content.filter( ( item - ): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' + ): item is + | Anthropic.Messages.TextBlock + | Anthropic.Messages.ThinkingBlock + | Anthropic.Messages.RedactedThinkingBlock + | Anthropic.Messages.ToolUseBlock => + item.type === 'text' || + item.type === 'thinking' || + item.type === 'redacted_thinking' || + item.type === 'tool_use' ) - // Add ONE assistant message with thinking + tool_use blocks - if (toolUseBlocks.length > 0) { + if (assistantBlocks.some((item) => item.type === 'tool_use')) { currentMessages.push({ role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], + content: assistantBlocks, }) } @@ -1258,21 +847,7 @@ export async function executeAnthropicProviderRequest( modelTime += thisModelTime - if (currentResponse.usage) { - tokens.input += currentResponse.usage.input_tokens || 0 - tokens.output += currentResponse.usage.output_tokens || 0 - tokens.total += - (currentResponse.usage.input_tokens || 0) + (currentResponse.usage.output_tokens || 0) - - const iterationCost = calculateCost( - request.model, - currentResponse.usage.input_tokens || 0, - currentResponse.usage.output_tokens || 0 - ) - cost.input += iterationCost.input - cost.output += iterationCost.output - cost.total += iterationCost.total - } + addAnthropicUsage(usage, currentResponse.usage) iterationCount++ } @@ -1284,6 +859,7 @@ export async function executeAnthropicProviderRequest( .join('\n') enrichLastModelSegmentFromAnthropicResponse(timeSegments, currentResponse, trailingText, { model: request.model, + providerId, }) } } catch (error) { @@ -1294,96 +870,14 @@ export async function executeAnthropicProviderRequest( const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - - if (request.stream) { - logger.info(`Using streaming for final ${providerLabel} response after tool processing`) - - /** Same regeneration guard as the primary path: prose only, no re-calls. */ - const streamingPayload = { - ...payload, - messages: currentMessages, - stream: true, - tool_choice: payload.tools?.length ? ({ type: 'none' } as const) : undefined, - } - - const streamResponse = await anthropic.messages.create( - streamingPayload as Anthropic.Messages.MessageCreateParamsStreaming, - 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: 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, - streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAnthropicStream( - streamResponse as AsyncIterable, - ({ content: streamContent, usage, thinking }) => { - if (!streamContent && content) { - logger.warn( - `${providerLabel} final stream produced no text; keeping tool-loop answer` - ) - } - output.content = streamContent || content - output.tokens = { - input: tokens.input + usage.input_tokens, - output: tokens.output + usage.output_tokens, - total: tokens.total + usage.input_tokens + usage.output_tokens, - } - - const streamCost = calculateCost( - request.model, - usage.input_tokens, - usage.output_tokens - ) - const tc2 = sumToolCosts(toolResults) - output.cost = { - input: cost.input + streamCost.input, - output: cost.output + streamCost.output, - toolCost: tc2 || undefined, - total: cost.total + streamCost.total + tc2, - } - - if (thinking) { - const segments = output.providerTiming?.timeSegments - const lastModel = segments - ? [...segments].reverse().find((segment) => segment.type === 'model') - : undefined - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - - finalizeTiming() - } - ), - }) - - return streamingResult - } + const tokens = buildAnthropicUsageTokens(usage) + const cost = buildAnthropicUsageCost(request.model, usage) return { content, model: request.model, tokens, + cost, toolCalls: toolCalls.length > 0 ? toolCalls.map((tc) => ({ @@ -1417,6 +911,10 @@ export async function executeAnthropicProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, @@ -1436,6 +934,7 @@ function enrichLastModelSegmentFromAnthropicResponse( textContent: string, extras?: { model?: string + providerId?: string ttft?: number errorType?: string errorMessage?: string @@ -1461,17 +960,12 @@ function enrichLastModelSegmentFromAnthropicResponse( : {}, })) - const segmentTokens = response.usage ? buildAnthropicSegmentTokens(response.usage) : undefined - + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, response.usage) + const segmentTokens = buildAnthropicUsageTokens(usage) let cost: { input: number; output: number; total: number } | undefined - if ( - extras?.model && - segmentTokens && - typeof segmentTokens.input === 'number' && - typeof segmentTokens.output === 'number' - ) { - const useCached = (segmentTokens.cacheRead ?? 0) > 0 - const full = calculateCost(extras.model, segmentTokens.input, segmentTokens.output, useCached) + if (extras?.model) { + const full = buildAnthropicUsageCost(extras.model, usage) cost = { input: full.input, output: full.output, total: full.total } } @@ -1482,29 +976,9 @@ function enrichLastModelSegmentFromAnthropicResponse( finishReason: response.stop_reason ?? undefined, tokens: segmentTokens, cost, - provider: 'anthropic', + provider: extras?.providerId, ttft: extras?.ttft, errorType: extras?.errorType, errorMessage: extras?.errorMessage, }) } - -/** - * Builds a segment token breakdown from Anthropic usage data, surfacing prompt - * cache reads/writes separately and producing a corrected `total` that includes - * cache_creation tokens (which Anthropic bills as input tokens but omits from - * `input_tokens`). - */ -function buildAnthropicSegmentTokens(usage: Anthropic.Messages.Message['usage']): BlockTokens { - const input = usage.input_tokens ?? 0 - const output = usage.output_tokens ?? 0 - const cacheRead = usage.cache_read_input_tokens ?? 0 - const cacheWrite = usage.cache_creation_input_tokens ?? 0 - return { - input, - output, - total: input + output + cacheRead + cacheWrite, - ...(cacheRead > 0 && { cacheRead }), - ...(cacheWrite > 0 && { cacheWrite }), - } -} diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 043ae4b0f09..80129eb3cfc 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -22,18 +22,9 @@ export const anthropicProvider: ProviderConfig = { 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, - }) - ) + createClient: (apiKey) => { + const cacheKey = `anthropic::${apiKey}` + return getCachedProviderClient(cacheKey, () => new Anthropic({ apiKey })) }, logger, }) diff --git a/apps/sim/providers/anthropic/request-history.test.ts b/apps/sim/providers/anthropic/request-history.test.ts new file mode 100644 index 00000000000..9dfac540180 --- /dev/null +++ b/apps/sim/providers/anthropic/request-history.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { convertAnthropicRequestHistory } from '@/providers/anthropic/request-history' + +describe('convertAnthropicRequestHistory', () => { + it('merges system history into the top-level prompt and preserves ordinary messages', () => { + const result = convertAnthropicRequestHistory({ + systemPrompt: 'Base instructions', + providerId: 'anthropic', + messages: [ + { role: 'system', content: 'First historical instruction' }, + { role: 'user', content: 'Hello' }, + { role: 'system', content: 'Second historical instruction' }, + { role: 'assistant', content: 'Hi there' }, + ], + }) + + expect(result.systemPrompt).toBe( + 'Base instructions\n\nFirst historical instruction\n\nSecond historical instruction' + ) + expect(result.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] }, + ]) + }) + + it('preserves modern tool IDs, parsed arguments, assistant text, and matching tool results', () => { + const result = convertAnthropicRequestHistory({ + providerId: 'anthropic', + messages: [ + { + role: 'assistant', + content: 'I will check both.', + tool_calls: [ + { + id: 'call-weather', + type: 'function', + function: { name: 'weather', arguments: '{"city":"Paris"}' }, + }, + { + id: 'call-time', + type: 'function', + function: { name: 'time', arguments: '{"timezone":"UTC"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call-weather', content: '' }, + { role: 'tool', tool_call_id: 'call-time', content: '00:00' }, + ], + }) + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'I will check both.' }, + { + type: 'tool_use', + id: 'call-weather', + name: 'weather', + input: { city: 'Paris' }, + }, + { + type: 'tool_use', + id: 'call-time', + name: 'time', + input: { timezone: 'UTC' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call-weather', content: '' }, + { type: 'tool_result', tool_use_id: 'call-time', content: '00:00' }, + ], + }, + ]) + }) + + it('pairs legacy function calls and results with stable deterministic IDs', () => { + const options = { + providerId: 'anthropic', + messages: [ + { + role: 'assistant' as const, + content: 'Checking.', + function_call: { name: 'lookup', arguments: '{"id":0}' }, + }, + { role: 'function' as const, name: 'lookup', content: 'false' }, + ], + } + + const first = convertAnthropicRequestHistory(options) + const second = convertAnthropicRequestHistory(options) + const firstToolUse = first.messages[0].content[1] + const secondToolUse = second.messages[0].content[1] + + expect(firstToolUse).toMatchObject({ + type: 'tool_use', + name: 'lookup', + input: { id: 0 }, + }) + expect(secondToolUse).toEqual(firstToolUse) + expect(first.messages[1]).toEqual({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: (firstToolUse as { id: string }).id, + content: 'false', + }, + ], + }) + }) + + it.each([ + ['malformed JSON', '{"id":'], + ['non-object JSON', '[]'], + ])('rejects %s legacy function arguments', (_label, args) => { + expect(() => + convertAnthropicRequestHistory({ + providerId: 'anthropic', + messages: [ + { + role: 'assistant', + content: null, + function_call: { name: 'lookup', arguments: args }, + }, + { role: 'function', name: 'lookup', content: 'result' }, + ], + }) + ).toThrow(/tool "lookup"/) + }) +}) diff --git a/apps/sim/providers/anthropic/request-history.ts b/apps/sim/providers/anthropic/request-history.ts new file mode 100644 index 00000000000..6909bda8747 --- /dev/null +++ b/apps/sim/providers/anthropic/request-history.ts @@ -0,0 +1,162 @@ +import type Anthropic from '@anthropic-ai/sdk' +import { buildAnthropicMessageContent } from '@/providers/attachments' +import { parseToolArguments } from '@/providers/streaming-tool-loop-shared' +import type { Message } from '@/providers/types' + +interface ConvertAnthropicRequestHistoryOptions { + messages?: Message[] + systemPrompt?: string + context?: string + providerId: string +} + +interface ConvertedAnthropicRequestHistory { + messages: Anthropic.Messages.MessageParam[] + systemPrompt: string +} + +interface PendingToolCall { + id: string + name: string +} + +/** + * Converts Sim's shared request history into Anthropic's message protocol. + */ +export function convertAnthropicRequestHistory({ + messages: sourceMessages = [], + systemPrompt, + context, + providerId, +}: ConvertAnthropicRequestHistoryOptions): ConvertedAnthropicRequestHistory { + const convertedMessages: Anthropic.Messages.MessageParam[] = [] + const systemParts = systemPrompt ? [systemPrompt] : [] + const pendingToolCalls = new Map() + let toolResultBlocks: Anthropic.Messages.ContentBlockParam[] | undefined + + if (context) { + convertedMessages.push({ + role: 'user', + content: [{ type: 'text', text: context }], + }) + } + + const assertNoPendingToolCalls = () => { + if (pendingToolCalls.size > 0) { + throw new Error( + `Anthropic request history is missing tool results for: ${[...pendingToolCalls.values()] + .map(({ name, id }) => `${name} (${id})`) + .join(', ')}` + ) + } + toolResultBlocks = undefined + } + + const registerToolCall = (toolCall: PendingToolCall) => { + if (!toolCall.id) { + throw new Error(`Anthropic tool call "${toolCall.name}" is missing an ID`) + } + if (pendingToolCalls.has(toolCall.id)) { + throw new Error(`Anthropic request history contains duplicate tool call ID "${toolCall.id}"`) + } + pendingToolCalls.set(toolCall.id, toolCall) + } + + const appendToolResult = (toolCallId: string | undefined, content: string | null) => { + if (!toolCallId || !pendingToolCalls.has(toolCallId)) { + throw new Error( + `Anthropic request history contains a tool result without a matching tool call${ + toolCallId ? `: ${toolCallId}` : '' + }` + ) + } + + const resultBlock: Anthropic.Messages.ToolResultBlockParam = { + type: 'tool_result', + tool_use_id: toolCallId, + ...(content !== null ? { content } : {}), + } + + if (!toolResultBlocks) { + toolResultBlocks = [resultBlock] + convertedMessages.push({ role: 'user', content: toolResultBlocks }) + } else { + toolResultBlocks.push(resultBlock) + } + + pendingToolCalls.delete(toolCallId) + } + + sourceMessages.forEach((message, messageIndex) => { + if (message.role === 'system') { + if (message.content) { + systemParts.push(message.content) + } + return + } + + if (message.role === 'tool') { + appendToolResult(message.tool_call_id, message.content) + return + } + + if (message.role === 'function') { + const matchingCall = [...pendingToolCalls.values()].find( + (toolCall) => toolCall.name === message.name + ) + appendToolResult(matchingCall?.id, message.content) + return + } + + assertNoPendingToolCalls() + + const content = buildAnthropicMessageContent(message.content, message.files, providerId) + if (message.role === 'assistant' && message.tool_calls?.length) { + const toolUseBlocks = message.tool_calls.map((toolCall) => { + const block: Anthropic.Messages.ToolUseBlockParam = { + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parseToolArguments(toolCall.function.arguments, toolCall.function.name), + } + registerToolCall({ id: block.id, name: block.name }) + return block + }) + convertedMessages.push({ + role: 'assistant', + content: [...content, ...toolUseBlocks], + }) + return + } + + if (message.role === 'assistant' && message.function_call) { + const toolUseId = `legacy-function-call-${messageIndex}` + const toolUseBlock: Anthropic.Messages.ToolUseBlockParam = { + type: 'tool_use', + id: toolUseId, + name: message.function_call.name, + input: parseToolArguments(message.function_call.arguments, message.function_call.name), + } + registerToolCall({ id: toolUseId, name: toolUseBlock.name }) + convertedMessages.push({ + role: 'assistant', + content: [...content, toolUseBlock], + }) + return + } + + if (content.length > 0) { + convertedMessages.push({ + role: message.role === 'assistant' ? 'assistant' : 'user', + content, + }) + } + }) + + assertNoPendingToolCalls() + + return { + messages: convertedMessages, + systemPrompt: systemParts.join('\n\n'), + } +} diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts index 311b1942cdb..c0f730af6c1 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -10,6 +10,7 @@ import { anthropicThinkingTextToolStreamEvents, } from '@/providers/__fixtures__/anthropic' import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import type { AnthropicUsageLike } from '@/providers/anthropic/usage' import type { AgentStreamEvent } from '@/providers/stream-events' import type { TimeSegment } from '@/providers/types' @@ -44,7 +45,7 @@ async function collectEvents( function makeFinalMessage(overrides: { content: unknown[] - usage?: { input_tokens: number; output_tokens: number } + usage?: AnthropicUsageLike stop_reason?: string | null }) { return { @@ -107,7 +108,12 @@ describe('createAnthropicStreamingToolLoopStream', () => { input: { city: 'San Francisco' }, }, ], - usage: { input_tokens: 42, output_tokens: 30 }, + usage: { + input_tokens: 42, + cache_read_input_tokens: 8, + cache_creation_input_tokens: 10, + output_tokens: 30, + }, stop_reason: 'tool_use', }) @@ -138,7 +144,15 @@ describe('createAnthropicStreamingToolLoopStream', () => { ] const finalTurnMessage = makeFinalMessage({ content: [{ type: 'text', text: 'It is 68°F in San Francisco.' }], - usage: { input_tokens: 100, output_tokens: 12 }, + usage: { + input_tokens: 100, + cache_creation_input_tokens: 5, + cache_creation: { + ephemeral_5m_input_tokens: 2, + ephemeral_1h_input_tokens: 3, + }, + output_tokens: 12, + }, stop_reason: 'end_turn', }) @@ -178,6 +192,7 @@ describe('createAnthropicStreamingToolLoopStream', () => { tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], } as any, messages: [{ role: 'user', content: 'Weather?' }], + providerId: 'anthropic', logger, timeSegments, onComplete, @@ -225,7 +240,9 @@ describe('createAnthropicStreamingToolLoopStream', () => { expect(onComplete.mock.calls[0][0].tokens).toEqual({ input: 142, output: 42, - total: 184, + total: 207, + cacheRead: 8, + cacheWrite: 15, }) expect(onComplete.mock.calls[0][0].content).toContain('68°F') expect(mockExecuteTool).toHaveBeenCalled() @@ -309,6 +326,7 @@ describe('createAnthropicStreamingToolLoopStream', () => { abortSignal: abortController.signal, } as any, messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', logger, timeSegments: [], onComplete: vi.fn(), @@ -338,4 +356,233 @@ describe('createAnthropicStreamingToolLoopStream', () => { status: 'cancelled', }) }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'message_start', + message: { usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{}' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 3 }, + }, + { type: 'message_stop' }, + ], + makeFinalMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 5, output_tokens: 3 }, + }) + ) + ), + }, + } as any + const onComplete = vi.fn() + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ + tokens: expect.objectContaining({ input: 5, output: 3, total: 8 }), + }) + ) + }) + + it('finalizes truncated text when max_tokens is reached without a tool call', async () => { + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Partial answer' }, + }, + ], + makeFinalMessage({ + content: [{ type: 'text', text: 'Partial answer' }], + stop_reason: 'max_tokens', + }) + ) + ), + }, + } as any + const onComplete = vi.fn() + const timeSegments: TimeSegment[] = [] + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'x' }], + }, + request: { model: 'claude-sonnet-4-5' } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments, + onComplete, + }) + + await expect(collectEvents(stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Partial answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Partial answer', + tokens: expect.objectContaining({ input: 10, output: 20, total: 30 }), + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'max_tokens', + assistantContent: 'Partial answer', + }) + }) + + it('rejects a max_tokens turn containing a partial tool call', async () => { + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_partial', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"city":' }, + }, + ], + makeFinalMessage({ + content: [], + stop_reason: 'max_tokens', + }) + ) + ), + }, + } as any + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'x' }], + providerId: 'anthropic', + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toMatchObject({ + message: 'Anthropic stream ended with stop_reason max_tokens', + }) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_partial', + name: 'get_weather', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_partial', + name: 'get_weather', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts index 6b604732c38..1a87ae4a3d8 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -12,27 +12,32 @@ import type Anthropic from '@anthropic-ai/sdk' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { BlockTokens, IterationToolCall } from '@/executor/types' +import { isRecordLike } from '@sim/utils/object' +import type { IterationToolCall } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' import { checkForForcedToolUsage } from '@/providers/anthropic/utils' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { isAbortError, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' -import { calculateCost, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' import { executeTool } from '@/tools' -/** - * Message params plus `output_format`, shared with `core.ts`: Sim's structured - * outputs ride the anthropic-beta header with a top-level `output_format` - * field, which the SDK does not model (it exposes `output_config.format`). - */ -export type AnthropicStreamingToolLoopPayload = Anthropic.Messages.MessageStreamParams & { - output_format?: { type: 'json_schema'; schema: Record } +export type AnthropicStreamingToolLoopPayload = Anthropic.Messages.MessageStreamParams + +type AnthropicStreamingToolLoopComplete = Omit & { + tokens: ReturnType } export interface CreateAnthropicStreamingToolLoopStreamOptions { @@ -40,33 +45,21 @@ export interface CreateAnthropicStreamingToolLoopStreamOptions { payload: AnthropicStreamingToolLoopPayload request: ProviderRequest messages: Anthropic.Messages.MessageParam[] + providerId: string logger: Logger /** Shared mutable segments; same array reference passed into createStreamingExecution. */ timeSegments: TimeSegment[] /** Forced tool names from prepareToolsWithUsageControl (may be empty). */ forcedTools?: string[] - onComplete: (result: StreamingToolLoopComplete) => void -} - -function buildSegmentTokens(usage: Anthropic.Messages.Usage): BlockTokens { - const input = usage.input_tokens ?? 0 - const output = usage.output_tokens ?? 0 - const cacheRead = usage.cache_read_input_tokens ?? 0 - const cacheWrite = usage.cache_creation_input_tokens ?? 0 - return { - input, - output, - total: input + output + cacheRead + cacheWrite, - ...(cacheRead > 0 && { cacheRead }), - ...(cacheWrite > 0 && { cacheWrite }), - } + onComplete: (result: AnthropicStreamingToolLoopComplete) => void } function enrichModelSegment( timeSegments: TimeSegment[], response: Anthropic.Messages.Message, textContent: string, - model: string + model: string, + providerId: string ): void { const thinkingBlocks = response.content.filter( (item): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => @@ -88,17 +81,10 @@ function enrichModelSegment( : {}, })) - const segmentTokens = response.usage ? buildSegmentTokens(response.usage) : undefined - let cost: { input: number; output: number; total: number } | undefined - if ( - segmentTokens && - typeof segmentTokens.input === 'number' && - typeof segmentTokens.output === 'number' - ) { - const useCached = (segmentTokens.cacheRead ?? 0) > 0 - const full = calculateCost(model, segmentTokens.input, segmentTokens.output, useCached) - cost = { input: full.input, output: full.output, total: full.total } - } + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, response.usage) + const segmentTokens = buildAnthropicUsageTokens(usage) + const segmentCost = buildAnthropicUsageCost(model, usage) enrichLastModelSegment(timeSegments, { assistantContent: textContent || undefined, @@ -106,8 +92,12 @@ function enrichModelSegment( toolCalls: toolCalls.length > 0 ? toolCalls : undefined, finishReason: response.stop_reason ?? undefined, tokens: segmentTokens, - cost, - provider: 'anthropic', + cost: { + input: segmentCost.input, + output: segmentCost.output, + total: segmentCost.total, + }, + provider: providerId, }) } @@ -117,8 +107,19 @@ function enrichModelSegment( export function createAnthropicStreamingToolLoopStream( options: CreateAnthropicStreamingToolLoopStreamOptions ): ReadableStream { - const { anthropic, payload, request, messages, logger, timeSegments, onComplete } = options + const { anthropic, payload, request, messages, providerId, logger, timeSegments, onComplete } = + options const forcedToolNames = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeMessageStream: { abort: () => void } | undefined + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } return new ReadableStream({ async start(controller) { @@ -134,17 +135,32 @@ export function createAnthropicStreamingToolLoopStream( let modelTime = 0 let toolsTime = 0 let firstResponseTime = 0 - const tokens = { input: 0, output: 0, total: 0 } + const usage = createAnthropicUsageAccumulator() const toolCalls: unknown[] = [] const toolResults: Record[] = [] /** Tools that received start but not yet end (abort settlement). */ const openToolStarts = new Map() - const streamOptions = request.abortSignal ? { signal: request.abortSignal } : undefined + const streamOptions = { signal: loopAbortController.signal } + const reportProgress = () => { + const tokens = buildAnthropicUsageTokens(usage) + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: buildAnthropicUsageCost(request.model, usage, toolCost), + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { const abortErr = new DOMException('Stream aborted', 'AbortError') settleOpenTools(controller, openToolStarts, 'cancelled') throw abortErr @@ -154,12 +170,17 @@ export function createAnthropicStreamingToolLoopStream( ...payload, messages: currentMessages, } + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS // Streaming tool loop always streams each turn; never pass stream:true twice. ;(turnPayload as { stream?: boolean }).stream = undefined + if (finalSynthesis) { + turnPayload.tool_choice = { type: 'none' } + } // Forced tool_choice vs thinking — same rules as silent loop. const thinkingEnabled = !!payload.thinking if ( + !finalSynthesis && !thinkingEnabled && typeof originalToolChoice === 'object' && hasUsedForcedTool && @@ -172,6 +193,7 @@ export function createAnthropicStreamingToolLoopStream( turnPayload.tool_choice = undefined } } else if ( + !finalSynthesis && !thinkingEnabled && hasUsedForcedTool && typeof originalToolChoice === 'object' @@ -181,21 +203,12 @@ export function createAnthropicStreamingToolLoopStream( const modelStart = Date.now() const messageStream = anthropic.messages.stream(turnPayload, streamOptions) + activeMessageStream = messageStream const textChunks: string[] = [] - let inputTokens = 0 - let outputTokens = 0 try { for await (const event of messageStream) { - if (event.type === 'message_start') { - inputTokens = event.message.usage?.input_tokens ?? 0 - continue - } - if (event.type === 'message_delta') { - outputTokens = event.usage?.output_tokens ?? outputTokens - continue - } if (event.type === 'content_block_start') { const block = event.content_block if (block.type === 'tool_use' && block.id && block.name) { @@ -225,6 +238,7 @@ export function createAnthropicStreamingToolLoopStream( } const finalMessage = await messageStream.finalMessage() + activeMessageStream = undefined const modelEnd = Date.now() const thisModelTime = modelEnd - modelStart modelTime += thisModelTime @@ -241,12 +255,7 @@ export function createAnthropicStreamingToolLoopStream( duration: thisModelTime, }) - // Prefer finalMessage.usage when present (includes cache fields). - const turnInput = finalMessage.usage?.input_tokens ?? inputTokens - const turnOutput = finalMessage.usage?.output_tokens ?? outputTokens - tokens.input += turnInput - tokens.output += turnOutput - tokens.total += turnInput + turnOutput + addAnthropicUsage(usage, finalMessage.usage) const textContent = finalMessage.content .filter((item): item is Anthropic.Messages.TextBlock => item.type === 'text') @@ -264,13 +273,28 @@ export function createAnthropicStreamingToolLoopStream( */ const toolsExecutable = finalMessage.stop_reason === 'tool_use' if (toolUses.length > 0 && !toolsExecutable) { - logger.warn('Skipping tool execution for incomplete turn', { - stopReason: finalMessage.stop_reason, - toolCount: toolUses.length, - }) settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Anthropic returned tool use with stop_reason ${finalMessage.stop_reason ?? 'missing'}` + ) + } + if (finalSynthesis && toolUses.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Anthropic returned tool use during final synthesis') } const executableToolUses = toolsExecutable ? toolUses : [] + const cappedTextTurn = + finalMessage.stop_reason === 'max_tokens' && openToolStarts.size === 0 + if ( + executableToolUses.length === 0 && + finalMessage.stop_reason !== 'end_turn' && + finalMessage.stop_reason !== 'stop_sequence' && + !cappedTextTurn + ) { + throw new Error( + `Anthropic stream ended with stop_reason ${finalMessage.stop_reason ?? 'missing'}` + ) + } const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' // If the SDK assembled text but we somehow missed deltas, still emit it @@ -279,13 +303,9 @@ export function createAnthropicStreamingToolLoopStream( controller.enqueue({ type: 'text_delta', text: textContent, turn: 'pending' }) } controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (textChunks.length > 0 || textContent) { - // Streamed deltas are the answer bytes; fall back to assembled text. - // Intermediate text is kept so a MAX_TOOL_ITERATIONS exit still has content. - content = textChunks.length > 0 ? textChunks.join('') : textContent - } + content = textChunks.length > 0 ? textChunks.join('') : textContent - enrichModelSegment(timeSegments, finalMessage, textContent, request.model) + enrichModelSegment(timeSegments, finalMessage, textContent, request.model, providerId) const forcedCheck = checkForForcedToolUsage( finalMessage, @@ -310,12 +330,15 @@ export function createAnthropicStreamingToolLoopStream( executableToolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name - const toolArgs = (toolUse.input ?? {}) as Record + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined try { - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { throw new DOMException('Stream aborted', 'AbortError') } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -350,7 +373,7 @@ export function createAnthropicStreamingToolLoopStream( request ) const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, + signal: loopAbortController.signal, }) const toolCallEndTime = Date.now() const value = { @@ -374,14 +397,32 @@ export function createAnthropicStreamingToolLoopStream( return value } catch (error) { const toolCallEndTime = Date.now() - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing tool call:', { error, toolName }) + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'cancelled', + }) + throw error } + if (isAbortError(error)) { + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) const value = { toolUse, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams: {} as Record, result: { success: false as const, @@ -391,7 +432,7 @@ export function createAnthropicStreamingToolLoopStream( startTime: toolCallStartTime, endTime: toolCallEndTime, duration: toolCallEndTime - toolCallStartTime, - status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + status: 'error' as ToolCallEndStatus, } openToolStarts.delete(toolUse.id) controller.enqueue({ @@ -405,7 +446,6 @@ export function createAnthropicStreamingToolLoopStream( }) ) - const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] for (const value of orderedResults) { @@ -430,9 +470,11 @@ export function createAnthropicStreamingToolLoopStream( }) let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output as Record) - resultContent = result.output + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -451,36 +493,32 @@ export function createAnthropicStreamingToolLoopStream( success: result.success, }) - toolUseBlocks.push({ - type: 'tool_use', - id: toolUse.id, - name: toolName, - input: toolArgs, - }) - toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUse.id, content: JSON.stringify(resultContent), + is_error: !result.success, }) } - const thinkingBlocks = finalMessage.content.filter( + const assistantBlocks = finalMessage.content.filter( ( item ): item is + | Anthropic.Messages.TextBlock | Anthropic.Messages.ThinkingBlock - | Anthropic.Messages.RedactedThinkingBlock => - item.type === 'thinking' || item.type === 'redacted_thinking' + | Anthropic.Messages.RedactedThinkingBlock + | Anthropic.Messages.ToolUseBlock => + item.type === 'text' || + item.type === 'thinking' || + item.type === 'redacted_thinking' || + item.type === 'tool_use' ) - if (toolUseBlocks.length > 0) { + if (assistantBlocks.some((item) => item.type === 'tool_use')) { currentMessages.push({ role: 'assistant', - content: [ - ...thinkingBlocks, - ...toolUseBlocks, - ] as Anthropic.Messages.ContentBlockParam[], + content: assistantBlocks, }) } if (toolResultBlocks.length > 0) { @@ -493,52 +531,49 @@ export function createAnthropicStreamingToolLoopStream( toolsTime += Date.now() - toolsStartTime iterationCount++ - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { settleOpenTools(controller, openToolStarts, 'cancelled') throw new DOMException('Stream aborted', 'AbortError') } } catch (error) { - settleOpenTools(controller, openToolStarts, isAbortError(error) ? 'cancelled' : 'error') + settleOpenTools( + controller, + openToolStarts, + loopAbortController.signal.aborted ? 'cancelled' : 'error' + ) throw error } } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + if (!sawFinalTurn) { + throw new Error('Anthropic tool loop ended without a final response') } - const modelCost = calculateCost(request.model, tokens.input, tokens.output) - const toolCostTotal = sumToolCosts(toolResults) - const cost = { - input: modelCost.input, - output: modelCost.output, - total: modelCost.total + (toolCostTotal || 0), - ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), - } - - onComplete({ - content, - tokens, - cost, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) - + reportProgress() controller.close() } catch (error) { - const cancelled = isAbortError(error) - settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') - controller.error(toError(error)) + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Anthropic streaming tool loop failed', { + error: toError(cause).message, + }), + }) + } finally { + activeMessageStream = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) } }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + activeMessageStream?.abort() + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, }) } diff --git a/apps/sim/providers/anthropic/usage.test.ts b/apps/sim/providers/anthropic/usage.test.ts new file mode 100644 index 00000000000..5ee1fd12c77 --- /dev/null +++ b/apps/sim/providers/anthropic/usage.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + addAnthropicUsage, + buildAnthropicUsageCost, + buildAnthropicUsageTokens, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' + +const MODEL = 'claude-sonnet-4-5' + +describe('Anthropic usage aggregation', () => { + it('prices uncached input and output normally', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { input_tokens: 1_000_000, output_tokens: 1_000_000 }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 1_000_000, + output: 1_000_000, + total: 2_000_000, + cacheRead: 0, + cacheWrite: 0, + }) + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3, + output: 15, + total: 18, + }) + }) + + it('prices cache reads at cached-input rates without discounting uncached input', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3.3, + output: 0, + total: 3.3, + }) + }) + + it('prices cache writes without details at the default five-minute tier', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 3.75, + total: 3.75, + }) + }) + + it('prices one-hour cache writes at twice the normal input rate', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 0, + cache_creation_input_tokens: 1_000_000, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 1_000_000, + }, + output_tokens: 0, + }) + + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 6, + total: 6, + }) + }) + + it('aggregates mixed uncached, read, five-minute, one-hour, and output usage', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 100_000, + cache_read_input_tokens: 200_000, + cache_creation_input_tokens: 300_000, + cache_creation: { + ephemeral_5m_input_tokens: 100_000, + ephemeral_1h_input_tokens: 200_000, + }, + output_tokens: 400_000, + }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 100_000, + output: 400_000, + total: 1_000_000, + cacheRead: 200_000, + cacheWrite: 300_000, + }) + expect(buildAnthropicUsageCost(MODEL, usage)).toMatchObject({ + input: 1.935, + output: 6, + total: 7.935, + }) + }) + + it('accumulates each stream turn exactly once', () => { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + output_tokens: 40, + }) + addAnthropicUsage(usage, { + input_tokens: 1, + cache_read_input_tokens: 2, + cache_creation_input_tokens: 3, + output_tokens: 4, + }) + + expect(buildAnthropicUsageTokens(usage)).toEqual({ + input: 11, + output: 44, + total: 110, + cacheRead: 22, + cacheWrite: 33, + }) + }) +}) diff --git a/apps/sim/providers/anthropic/usage.ts b/apps/sim/providers/anthropic/usage.ts new file mode 100644 index 00000000000..5f40f8b5408 --- /dev/null +++ b/apps/sim/providers/anthropic/usage.ts @@ -0,0 +1,145 @@ +import type { BlockTokens } from '@/executor/types' +import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy' +import type { ModelPricing } from '@/providers/types' + +export interface AnthropicUsageLike { + input_tokens?: number | null + output_tokens?: number | null + cache_read_input_tokens?: number | null + cache_creation_input_tokens?: number | null + cache_creation?: { + ephemeral_5m_input_tokens?: number | null + ephemeral_1h_input_tokens?: number | null + } | null +} + +export interface AnthropicUsageAccumulator { + input: number + output: number + cacheRead: number + cacheWriteFiveMinute: number + cacheWriteOneHour: number +} + +interface AnthropicUsageCost { + input: number + output: number + total: number + toolCost?: number + pricing: ModelPricing +} + +function tokenCount(value: number | null | undefined): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} + +function roundedCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Creates an empty accumulator for one Anthropic provider request. + */ +export function createAnthropicUsageAccumulator(): AnthropicUsageAccumulator { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWriteFiveMinute: 0, + cacheWriteOneHour: 0, + } +} + +/** + * Adds one Anthropic model response's usage without counting cache tokens as uncached input. + */ +export function addAnthropicUsage( + accumulator: AnthropicUsageAccumulator, + usage: AnthropicUsageLike | null | undefined +): void { + if (!usage) return + + accumulator.input += tokenCount(usage.input_tokens) + accumulator.output += tokenCount(usage.output_tokens) + accumulator.cacheRead += tokenCount(usage.cache_read_input_tokens) + + const cacheWriteTotal = tokenCount(usage.cache_creation_input_tokens) + if (!usage.cache_creation) { + accumulator.cacheWriteFiveMinute += cacheWriteTotal + return + } + + const fiveMinute = tokenCount(usage.cache_creation.ephemeral_5m_input_tokens) + const oneHour = tokenCount(usage.cache_creation.ephemeral_1h_input_tokens) + const detailedTotal = fiveMinute + oneHour + + accumulator.cacheWriteFiveMinute += fiveMinute + Math.max(0, cacheWriteTotal - detailedTotal) + accumulator.cacheWriteOneHour += oneHour +} + +/** + * Builds the block token shape, including cache reads and writes in the total. + */ +export function buildAnthropicUsageTokens( + accumulator: AnthropicUsageAccumulator +): Required> { + const cacheWrite = accumulator.cacheWriteFiveMinute + accumulator.cacheWriteOneHour + return { + input: accumulator.input, + output: accumulator.output, + total: accumulator.input + accumulator.output + accumulator.cacheRead + cacheWrite, + cacheRead: accumulator.cacheRead, + cacheWrite, + } +} + +/** 5-minute cache writes cost 1.25x the base input rate, 1-hour writes 2x. */ +const FIVE_MINUTE_WRITE_MULTIPLIER = 1.25 +const ONE_HOUR_WRITE_MULTIPLIER = 2 + +/** + * Builds the normalized usage for one Anthropic request. + * + * Anthropic reports `input_tokens` already excluding cache reads and writes + * (`total_input = cache_read + cache_creation + input_tokens`), so `input` maps + * across directly — unlike OpenAI and Gemini, whose cached counts are subsets + * of their prompt totals and must be subtracted. + */ +export function buildAnthropicModelUsage(accumulator: AnthropicUsageAccumulator): ModelUsage { + return { + input: accumulator.input, + output: accumulator.output, + cacheRead: accumulator.cacheRead, + cacheWrites: [ + { + tokens: accumulator.cacheWriteFiveMinute, + inputRateMultiplier: FIVE_MINUTE_WRITE_MULTIPLIER, + }, + { tokens: accumulator.cacheWriteOneHour, inputRateMultiplier: ONE_HOUR_WRITE_MULTIPLIER }, + ], + } +} + +/** + * Prices one Anthropic request, cache tiers included, through the shared + * pricing function. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function buildAnthropicUsageCost( + model: string, + accumulator: AnthropicUsageAccumulator, + toolCost = 0 +): AnthropicUsageCost { + const cost = priceModelUsage(model, buildAnthropicModelUsage(accumulator), LIST_PRICE_POLICY) + + return { + input: cost.input, + output: cost.output, + total: roundedCost(cost.total + toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: cost.pricing, + } +} diff --git a/apps/sim/providers/anthropic/utils.test.ts b/apps/sim/providers/anthropic/utils.test.ts index 7d2fb8d4e4e..6c284067bdd 100644 --- a/apps/sim/providers/anthropic/utils.test.ts +++ b/apps/sim/providers/anthropic/utils.test.ts @@ -60,7 +60,50 @@ describe('createReadableStreamFromAnthropicStream', () => { expect(onComplete.mock.calls[0][0]).toMatchObject({ content: anthropicThinkingTextToolExpectedText, thinking: anthropicThinkingTextToolExpectedThinking, - usage: { input_tokens: 42, output_tokens: expect.any(Number) }, + usage: { input: 42, output: expect.any(Number) }, + }) + }) + + it('captures stream cache usage once from cumulative message events', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield { + type: 'message_start', + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + cache_creation: { + ephemeral_5m_input_tokens: 10, + ephemeral_1h_input_tokens: 20, + }, + }, + }, + } + yield { + type: 'message_delta', + usage: { + input_tokens: 10, + output_tokens: 40, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + }, + } + })() as AsyncIterable, + onComplete + ) + + await collectEvents(stream) + + expect(onComplete.mock.calls[0][0].usage).toEqual({ + input: 10, + output: 40, + cacheRead: 20, + cacheWriteFiveMinute: 10, + cacheWriteOneHour: 20, }) }) diff --git a/apps/sim/providers/anthropic/utils.ts b/apps/sim/providers/anthropic/utils.ts index c9fcb89ac90..ad6ac2958f9 100644 --- a/apps/sim/providers/anthropic/utils.ts +++ b/apps/sim/providers/anthropic/utils.ts @@ -1,19 +1,19 @@ import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import { createLogger } from '@sim/logger' -import { randomFloat } from '@sim/utils/random' +import { + type AnthropicUsageAccumulator, + type AnthropicUsageLike, + addAnthropicUsage, + createAnthropicUsageAccumulator, +} from '@/providers/anthropic/usage' import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('AnthropicUtils') -export interface AnthropicStreamUsage { - input_tokens: number - output_tokens: number -} - export interface AnthropicStreamComplete { content: string - usage: AnthropicStreamUsage + usage: AnthropicUsageAccumulator /** Assembled thinking text for traces (redacted blocks become `[redacted]`). */ thinking: string } @@ -28,14 +28,16 @@ export function createReadableStreamFromAnthropicStream( anthropicStream: AsyncIterable, onComplete?: (result: AnthropicStreamComplete) => void ): ReadableStream { + let cancelled = false + let streamIterator: AsyncIterator | undefined + return new ReadableStream({ async start(controller) { try { let fullContent = '' const thinkingBlocks: string[] = [] let currentThinking = '' - let inputTokens = 0 - let outputTokens = 0 + let usageSnapshot: AnthropicUsageLike = {} const flushThinkingBlock = () => { if (currentThinking) { @@ -44,14 +46,27 @@ export function createReadableStreamFromAnthropicStream( } } - for await (const event of anthropicStream) { + streamIterator = anthropicStream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const event = next.value if (event.type === 'message_start') { - inputTokens = event.message.usage.input_tokens + usageSnapshot = event.message.usage continue } if (event.type === 'message_delta') { - outputTokens = event.usage.output_tokens + usageSnapshot = { + ...usageSnapshot, + input_tokens: event.usage.input_tokens ?? usageSnapshot.input_tokens, + output_tokens: event.usage.output_tokens ?? usageSnapshot.output_tokens, + cache_read_input_tokens: + event.usage.cache_read_input_tokens ?? usageSnapshot.cache_read_input_tokens, + cache_creation_input_tokens: + event.usage.cache_creation_input_tokens ?? + usageSnapshot.cache_creation_input_tokens, + } continue } @@ -89,29 +104,33 @@ export function createReadableStreamFromAnthropicStream( } } + if (cancelled) return flushThinkingBlock() if (onComplete) { + const usage = createAnthropicUsageAccumulator() + addAnthropicUsage(usage, usageSnapshot) onComplete({ content: fullContent, - usage: { input_tokens: inputTokens, output_tokens: outputTokens }, - // Match enrichLastModelSegmentFromAnthropicResponse: join blocks with blank lines. + usage, thinking: thinkingBlocks.filter(Boolean).join('\n\n'), }) } controller.close() } catch (err) { - controller.error(err) + if (!cancelled) { + controller.error(err) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } -export function generateToolUseId(toolName: string): string { - return `${toolName}-${Date.now()}-${randomFloat().toString(36).substring(2, 7)}` -} - export function checkForForcedToolUsage( response: any, toolChoice: any, diff --git a/apps/sim/providers/azure-anthropic/index.test.ts b/apps/sim/providers/azure-anthropic/index.test.ts index 0956aa0dac8..6ca390ca2b9 100644 --- a/apps/sim/providers/azure-anthropic/index.test.ts +++ b/apps/sim/providers/azure-anthropic/index.test.ts @@ -56,7 +56,7 @@ function request(overrides: Partial): ProviderRequest { /** Invokes the createClient factory handed to the Anthropic core and returns the SDK options it built. */ function buildClientOptions(): Record { const config = mockExecuteAnthropic.mock.calls[0][1] - config.createClient('k', false) + config.createClient('k') return anthropicArgs[0] } @@ -92,6 +92,18 @@ describe('azureAnthropicProvider — SSRF pinning', () => { expect(buildClientOptions()).not.toHaveProperty('fetch') }) + it('keeps the registry model in core and resolves a separate Azure wire model', async () => { + setEnv({ AZURE_ANTHROPIC_ENDPOINT: 'https://identity.services.ai.azure.com' }) + const providerRequest = request({}) + + await azureAnthropicProvider.executeRequest(providerRequest) + + const [forwardedRequest, config] = mockExecuteAnthropic.mock.calls[0] + expect(forwardedRequest.model).toBe('azure-anthropic/claude-3-5-sonnet') + expect(config.resolveWireModel(forwardedRequest)).toBe('claude-3-5-sonnet') + expect(buildClientOptions().defaultHeaders).not.toHaveProperty('anthropic-beta') + }) + it('throws and never builds a client when validation blocks the endpoint', async () => { mockValidate.mockResolvedValue({ isValid: false, error: 'resolves to a blocked IP address' }) diff --git a/apps/sim/providers/azure-anthropic/index.ts b/apps/sim/providers/azure-anthropic/index.ts index 2f0498992a0..fe7881755db 100644 --- a/apps/sim/providers/azure-anthropic/index.ts +++ b/apps/sim/providers/azure-anthropic/index.ts @@ -52,53 +52,40 @@ export const azureAnthropicProvider: ProviderConfig = { 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 normalizedEndpoint = azureEndpoint.replace(/\/$/, '') + const baseURL = normalizedEndpoint.endsWith('/anthropic') + ? normalizedEndpoint + : `${normalizedEndpoint}/anthropic` const anthropicVersion = request.azureApiVersion || env.AZURE_ANTHROPIC_API_VERSION || '2023-06-01' - return executeAnthropicProviderRequest( - { - ...request, - model: modelName, - apiKey, + return executeAnthropicProviderRequest(request, { + providerId: 'azure-anthropic', + providerLabel: 'Azure Anthropic', + resolveWireModel: ({ model }) => model.replace(/^azure-anthropic\//, ''), + createClient: (apiKey) => { + const cacheKey = [ + 'azure-anthropic', + apiKey, + baseURL, + anthropicVersion, + pinnedIP ?? 'no-pin', + ].join('::') + return getCachedProviderClient( + cacheKey, + () => + new Anthropic({ + baseURL, + apiKey, + ...(pinnedFetch ? { fetch: pinnedFetch } : {}), + defaultHeaders: { + 'anthropic-version': anthropicVersion, + }, + }) + ) }, - { - 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, - } - ) + logger, + }) }, } diff --git a/apps/sim/providers/azure-openai/index.test.ts b/apps/sim/providers/azure-openai/index.test.ts index f58c72ddfd6..44310504746 100644 --- a/apps/sim/providers/azure-openai/index.test.ts +++ b/apps/sim/providers/azure-openai/index.test.ts @@ -3,7 +3,8 @@ */ import { resetEnvMock, setEnv } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ProviderRequest } from '@/providers/types' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderRequest, ProviderToolConfig } from '@/providers/types' const { mockAzureOpenAI, @@ -15,6 +16,8 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint, mockIsResponsesEndpoint, + mockPrepareTools, + mockExecuteTool, } = vi.hoisted(() => { const azureOpenAIArgs: Array> = [] const sentinelFetch = vi.fn() @@ -35,6 +38,8 @@ const { sentinelFetch, mockIsChatCompletionsEndpoint: vi.fn(() => false), mockIsResponsesEndpoint: vi.fn(() => false), + mockPrepareTools: vi.fn(), + mockExecuteTool: vi.fn(), } }) @@ -73,14 +78,10 @@ vi.mock('@/providers/trace-enrichment', () => ({ 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: [], - })), + prepareToolsWithUsageControl: mockPrepareTools, sumToolCosts: vi.fn(() => 0), })) -vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { azureOpenAIProvider } from '@/providers/azure-openai/index' @@ -88,6 +89,26 @@ function request(overrides: Partial): ProviderRequest { return { model: 'azure/gpt-4o', apiKey: 'k', messages: [], ...overrides } } +function makeTool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + /** Config object passed to the Responses core on the Nth call. */ const responsesConfig = (call = 0) => mockExecuteResponses.mock.calls[call][1] @@ -101,6 +122,12 @@ describe('azureOpenAIProvider — SSRF pinning', () => { mockIsChatCompletionsEndpoint.mockReturnValue(false) mockIsResponsesEndpoint.mockReturnValue(false) mockExecuteResponses.mockResolvedValue({ content: 'ok' }) + mockPrepareTools.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + }) + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) }) describe('Responses API path', () => { @@ -188,5 +215,61 @@ describe('azureOpenAIProvider — SSRF pinning', () => { expect(mockCreatePinnedFetch).not.toHaveBeenCalled() expect(azureOpenAIArgs[0]).not.toHaveProperty('fetch') }) + + it('projects the settled tool-loop answer without a final streaming request', async () => { + mockIsChatCompletionsEndpoint.mockReturnValue(true) + mockValidate.mockResolvedValue({ isValid: true, resolvedIP: '203.0.113.10' }) + mockPrepareTools.mockReturnValue({ + tools: [{ type: 'function', function: { name: 'lookup' } }], + toolChoice: 'auto', + forcedTools: [], + }) + mockChatCreate + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'done', tool_calls: undefined } }], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }) + + const result = await azureOpenAIProvider.executeRequest( + request({ + azureEndpoint: 'https://rebind.attacker.tld/openai/deployments/gpt-4o/chat/completions', + stream: true, + tools: [makeTool('lookup')], + }) + ) + + expect(mockChatCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 6, output: 3, total: 9 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'done', turn: 'final' }]) + }) }) }) diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index 9280f8d0ee4..14c83fdd34c 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { AzureOpenAI } from 'openai' import type { ChatCompletion, @@ -27,7 +28,9 @@ import { } from '@/providers/azure-openai/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { executeResponsesProviderRequest } from '@/providers/openai/core' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -302,10 +305,25 @@ async function executeChatCompletionsRequest( const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -323,6 +341,9 @@ async function executeChatCompletionsRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -342,7 +363,7 @@ async function executeChatCompletionsRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -357,11 +378,9 @@ async function executeChatCompletionsRequest( })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -371,10 +390,12 @@ async function executeChatCompletionsRequest( duration: duration, }) - let resultContent: Record + let resultContent: unknown if (result.success) { - toolResults.push(result.output as Record) - resultContent = result.output as Record + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -472,28 +493,56 @@ async function executeChatCompletionsRequest( iterationCount++ } - if (request.stream) { - logger.info('Using streaming for final response after tool processing') - - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) - + if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. + * The capped turn still requests tools, so make one tool-disabled call to + * synthesize an answer from the tool results already gathered. */ - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } - const streamResponse = await azureOpenAI.chat.completions.create( - streamingParams, + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = (await azureOpenAI.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, request.abortSignal ? { signal: request.abortSignal } : undefined + )) as ChatCompletion + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'azure_openai' } ) + } - const streamingResult = createStreamingExecution({ + if (request.stream) { + logger.info('Projecting settled response after tool processing') + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -502,7 +551,7 @@ async function executeChatCompletionsRequest( modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -513,7 +562,8 @@ async function executeChatCompletionsRequest( initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -523,36 +573,12 @@ async function executeChatCompletionsRequest( } : undefined, streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromAzureOpenAIStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('Azure OpenAI final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || 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() - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -572,7 +598,7 @@ async function executeChatCompletionsRequest( modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -586,6 +612,10 @@ async function executeChatCompletionsRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/baseten/index.test.ts b/apps/sim/providers/baseten/index.test.ts index df296c6626e..d9e450ece2f 100644 --- a/apps/sim/providers/baseten/index.test.ts +++ b/apps/sim/providers/baseten/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/baseten/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,16 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = ( + assistant: { content?: string | null; reasoning_content?: string } = {} +) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning_content !== undefined + ? { reasoning_content: assistant.reasoning_content } + : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -224,15 +232,54 @@ describe('basetenProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Baseten assistant content and reasoning_content on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await basetenProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await basetenProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await basetenProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index a5f59d818bb..879efabd697 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { supportsNativeStructuredOutputs, } from '@/providers/baseten/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -263,10 +267,25 @@ export const basetenProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -284,6 +303,9 @@ export const basetenProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Baseten):', { error: toError(error).message, @@ -306,26 +328,21 @@ export const basetenProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -336,10 +353,12 @@ export const basetenProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -417,85 +436,61 @@ export const basetenProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'baseten' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'baseten', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - 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, - streamFormat: 'agent-events-v1', - 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, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - 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, - } - }), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'baseten' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -551,6 +546,45 @@ export const basetenProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -568,7 +602,7 @@ export const basetenProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -591,6 +625,10 @@ export const basetenProvider: ProviderConfig = { } logger.error('Error in Baseten request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/bedrock/index.test.ts b/apps/sim/providers/bedrock/index.test.ts index 3a9abceefbd..17d593d048c 100644 --- a/apps/sim/providers/bedrock/index.test.ts +++ b/apps/sim/providers/bedrock/index.test.ts @@ -22,6 +22,9 @@ vi.mock('@/providers/bedrock/utils', () => ({ checkForForcedToolUsage: vi.fn(), createReadableStreamFromBedrockStream: vi.fn(), generateToolUseId: vi.fn().mockReturnValue('tool-1'), + getBedrockStreamError: vi.fn().mockReturnValue(null), + // The mocked inference profile above is a Claude model, which supports it. + supportsToolResultStatus: vi.fn().mockReturnValue(true), })) vi.mock('@/providers/models', () => ({ @@ -31,11 +34,15 @@ vi.mock('@/providers/models', () => ({ INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, getProviderModels: vi.fn().mockReturnValue([]), getProviderDefaultModel: vi.fn().mockReturnValue('us.anthropic.claude-3-5-sonnet-20241022-v2:0'), + supportsNativeStructuredOutputs: vi.fn().mockReturnValue(false), })) vi.mock('@/providers/utils', () => ({ calculateCost: vi.fn().mockReturnValue({ input: 0, output: 0, total: 0, pricing: null }), - prepareToolExecution: vi.fn(), + prepareToolExecution: vi.fn((_tool, args) => ({ + toolParams: args, + executionParams: args, + })), prepareToolsWithUsageControl: vi.fn().mockReturnValue({ tools: [], toolChoice: 'auto', @@ -45,12 +52,14 @@ vi.mock('@/providers/utils', () => ({ })) vi.mock('@/tools', () => ({ - executeTool: vi.fn(), + executeTool: vi.fn().mockResolvedValue({ success: true, output: false }), })) -import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime' +import { BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime' +import type { StreamingExecution } from '@/executor/types' import { bedrockProvider } from '@/providers/bedrock/index' import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import { prepareToolsWithUsageControl } from '@/providers/utils' describe('bedrockProvider credential handling', () => { beforeEach(() => { @@ -120,4 +129,188 @@ describe('bedrockProvider credential handling', () => { region: 'eu-west-1', }) }) + + it('uses the live loop for streaming tool requests without a caller flag', async () => { + vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ + tools: [ + { + name: 'lookup', + description: 'Lookup', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + mockSend + .mockResolvedValueOnce({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { + toolUse: { + toolUseId: 'tool-1', + name: 'lookup', + }, + }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{}' } }, + }, + } + yield { metadata: { usage: { inputTokens: 1, outputTokens: 1 } } } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + }) + .mockResolvedValueOnce({ + stream: (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'settled answer' }, + }, + } + yield { metadata: { usage: { inputTokens: 2, outputTokens: 2 } } } + yield { messageStop: { stopReason: 'end_turn' } } + })(), + }) + + const result = (await bedrockProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + })) as StreamingExecution + + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + + expect(mockSend).toHaveBeenCalledTimes(2) + expect(result.execution.output.content).toBe('settled answer') + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + }) + + it('keeps the explicit structured-output extraction call before settled projection', async () => { + vi.mocked(prepareToolsWithUsageControl).mockReturnValueOnce({ + tools: [ + { + name: 'lookup', + description: 'Lookup', + input_schema: { type: 'object', properties: {}, required: [] }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + mockSend + .mockResolvedValueOnce({ + output: { + message: { + content: [ + { + toolUse: { + toolUseId: 'tool-1', + name: 'lookup', + input: {}, + }, + }, + ], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 1, outputTokens: 1 }, + }) + .mockResolvedValueOnce({ + output: { message: { content: [{ text: 'unformatted answer' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 2, outputTokens: 2 }, + }) + .mockResolvedValueOnce({ + output: { + message: { + content: [ + { + toolUse: { + toolUseId: 'structured-1', + name: 'structured_output', + input: { answer: 'formatted' }, + }, + }, + ], + }, + }, + stopReason: 'tool_use', + usage: { inputTokens: 3, outputTokens: 3 }, + }) + + const result = (await bedrockProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + required: ['answer'], + }, + }, + })) as StreamingExecution + + expect(mockSend).toHaveBeenCalledTimes(3) + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + expect(vi.mocked(ConverseCommand).mock.calls[2][0]).toMatchObject({ + toolConfig: { + tools: [ + { + toolSpec: { + name: 'structured_output', + }, + }, + ], + toolChoice: { tool: { name: 'structured_output' } }, + }, + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { + type: 'text_delta', + text: '{\n "answer": "formatted"\n}', + turn: 'final', + }, + }) + }) }) diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 1609080585d..544d823209b 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -7,6 +7,7 @@ import { ConverseCommand, type ConverseResponse, ConverseStreamCommand, + type OutputConfig, type SystemContentBlock, type Tool, type ToolConfiguration, @@ -15,6 +16,7 @@ import { } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { buildBedrockMessageContent } from '@/providers/attachments' @@ -24,10 +26,17 @@ import { createReadableStreamFromBedrockStream, generateToolUseId, getBedrockInferenceProfileId, + supportsToolResultStatus, } from '@/providers/bedrock/utils' import { getCachedProviderClient } from '@/providers/client-cache' -import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { + getProviderDefaultModel, + getProviderModels, + supportsNativeStructuredOutputs, +} from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, @@ -182,7 +191,7 @@ export const bedrockProvider: ProviderConfig = { const toolUseBlock: ToolUseBlock = { toolUseId: msg.tool_calls?.[0]?.id || generateToolUseId(toolCall.name), name: toolCall.name, - input: JSON.parse(toolCall.arguments), + input: parseToolArguments(toolCall.arguments, toolCall.name) as ToolUseBlock['input'], } messages.push({ role: 'assistant' as ConversationRole, @@ -210,23 +219,38 @@ export const bedrockProvider: ProviderConfig = { } let structuredOutputTool: Tool | undefined + let outputConfig: OutputConfig | 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, + if (supportsNativeStructuredOutputs(request.model) && !request.tools?.length) { + outputConfig = { + textFormat: { + type: 'json_schema', + structure: { + jsonSchema: { + name: schemaName, + schema: JSON.stringify(schema), + }, + }, }, - }, + } + logger.info(`Using native structured outputs: ${schemaName}`) + } else { + 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-based structured outputs: ${schemaName}`) } - - logger.info(`Using Tool Use approach for structured outputs: ${schemaName}`) } let bedrockTools: Tool[] | undefined @@ -248,52 +272,56 @@ export const bedrockProvider: ProviderConfig = { }, })) - 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 + preparedTools = prepareToolsWithUsageControl( + bedrockTools.map((t) => ({ + name: t.toolSpec?.name || '', + description: t.toolSpec?.description || '', + input_schema: t.toolSpec?.inputSchema?.json, + })), + request.tools, + logger, + 'bedrock' + ) - if (filteredTools?.length) { - bedrockTools = filteredTools.map((t: any) => ({ + const { tools: filteredTools, toolChoice: preparedToolChoice } = preparedTools + bedrockTools = filteredTools?.length + ? filteredTools.map((tool) => ({ toolSpec: { - name: t.name, - description: t.description, - inputSchema: { json: t.input_schema }, + name: tool.name, + description: tool.description, + inputSchema: { json: tool.input_schema }, }, })) + : undefined - 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: {} } - } + if (bedrockTools?.length) { + if (preparedToolChoice === 'auto') { + toolChoice = { auto: {} } + } else if (preparedToolChoice === 'none') { + toolChoice = undefined + bedrockTools = undefined + } else if ( + preparedToolChoice?.type === 'tool' && + typeof preparedToolChoice.name === 'string' && + preparedToolChoice.name.length > 0 + ) { + toolChoice = { tool: { name: preparedToolChoice.name } } + logger.info(`Using Bedrock tool_choice format: force tool "${preparedToolChoice.name}"`) + } else if ( + preparedToolChoice?.type === 'function' && + typeof preparedToolChoice.function?.name === 'string' && + preparedToolChoice.function.name.length > 0 + ) { + toolChoice = { tool: { name: preparedToolChoice.function.name } } + logger.info( + `Using Bedrock tool_choice format: force tool "${preparedToolChoice.function.name}"` + ) + } else if (preparedToolChoice?.type === 'any') { + toolChoice = { any: {} } + logger.info('Using Bedrock tool_choice format: any tool') + } else { + throw new Error('Invalid Bedrock tool choice returned by tool preparation') } - } catch (error) { - logger.error('Error in prepareToolsWithUsageControl:', { error }) - toolChoice = { auto: {} } } } else if (structuredOutputTool) { bedrockTools = [structuredOutputTool] @@ -353,9 +381,9 @@ export const bedrockProvider: ProviderConfig = { * Bedrock rides a final forced `structured_output` tool call that only the * silent loop performs — so those requests fall back to the silent path. */ - const shouldStreamToolCalls = (request.streamToolCalls ?? false) && !request.responseFormat + const liveToolLoopSupported = !request.responseFormat - if (request.stream && shouldStreamToolCalls && bedrockTools && bedrockTools.length > 0) { + if (request.stream && liveToolLoopSupported && bedrockTools && bedrockTools.length > 0) { logger.info('Using streaming tool loop for Bedrock request') const providerStartTime = Date.now() @@ -420,6 +448,7 @@ export const bedrockProvider: ProviderConfig = { messages, system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, inferenceConfig, + outputConfig, }) const streamResponse = await client.send( @@ -478,6 +507,7 @@ export const bedrockProvider: ProviderConfig = { messages, system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, inferenceConfig, + outputConfig, toolConfig, }) @@ -488,7 +518,6 @@ export const bedrockProvider: ProviderConfig = { 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 } => @@ -497,7 +526,6 @@ export const bedrockProvider: ProviderConfig = { 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( @@ -581,7 +609,12 @@ export const bedrockProvider: ProviderConfig = { ) const currentToolUses = toolUseContentBlocks.map((block) => block.toolUse) - if (!currentToolUses || currentToolUses.length === 0) { + if (currentToolUses.length > 0 && currentResponse.stopReason !== 'tool_use') { + throw new Error( + `Bedrock returned tool use with stop reason ${currentResponse.stopReason ?? 'missing'}` + ) + } + if (currentToolUses.length === 0) { break } @@ -590,12 +623,35 @@ export const bedrockProvider: ProviderConfig = { const toolExecutionPromises = currentToolUses.map(async (toolUse: ToolUseBlock) => { const toolCallStartTime = Date.now() const toolName = toolUse.name || '' - const toolArgs = (toolUse.input as Record) || {} + const toolArgs = + toolUse.input && typeof toolUse.input === 'object' && !Array.isArray(toolUse.input) + ? (toolUse.input as Record) + : undefined const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) try { + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolUseId, + toolName, + toolArgs, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { @@ -606,7 +662,7 @@ export const bedrockProvider: ProviderConfig = { return { toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams, result, startTime: toolCallStartTime, @@ -614,6 +670,9 @@ export const bedrockProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -634,15 +693,13 @@ export const bedrockProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) - const assistantContent: ContentBlock[] = currentToolUses.map((toolUse: ToolUseBlock) => ({ - toolUse: { - toolUseId: toolUse.toolUseId, - name: toolUse.name, - input: toolUse.input, - }, - })) + // Bedrock rejects a blank text block on replay even when it produced + // one itself, so drop whitespace-only text while echoing the rest. + const assistantContent: ContentBlock[] = ( + currentResponse.output?.message?.content ?? [] + ).filter((block) => !('text' in block) || Boolean(block.text?.trim())) currentMessages.push({ role: 'assistant' as ConversationRole, content: assistantContent, @@ -650,9 +707,7 @@ export const bedrockProvider: ProviderConfig = { const toolResultContent: ContentBlock[] = [] - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolUseId, toolName, @@ -662,7 +717,7 @@ export const bedrockProvider: ProviderConfig = { startTime, endTime, duration, - } = settledResult.value + } = executionResult timeSegments.push({ type: 'tool', @@ -672,10 +727,12 @@ export const bedrockProvider: ProviderConfig = { duration, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -697,6 +754,9 @@ export const bedrockProvider: ProviderConfig = { const toolResultBlock: ToolResultBlock = { toolUseId, content: [{ text: JSON.stringify(resultContent) }], + ...(supportsToolResultStatus(bedrockModelId) + ? { status: result.success ? 'success' : 'error' } + : {}), } toolResultContent.push({ toolResult: toolResultBlock }) } @@ -841,7 +901,6 @@ export const bedrockProvider: ProviderConfig = { 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') @@ -869,54 +928,9 @@ export const bedrockProvider: ProviderConfig = { 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 + if (request.stream && !liveToolLoopSupported) { + logger.info('Projecting settled Bedrock response after tool processing') + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, providerStartTime, @@ -926,49 +940,25 @@ export const bedrockProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, 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, + toolCost: toolCost || undefined, + total: cost.total + toolCost, }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output, finalizeTiming }) => - createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => { - /** - * Bedrock's ToolChoice has no `none`, and toolConfig is required - * when history carries toolUse blocks — the regeneration can - * re-call a tool that is never executed on this path. Keep the - * tool loop's settled answer when the stream ends without text. - */ - if (!streamContent && content) { - logger.warn('Bedrock final stream produced no text; keeping tool-loop answer') - } - output.content = streamContent || content - 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() - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -1003,7 +993,7 @@ export const bedrockProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, } @@ -1017,6 +1007,10 @@ export const bedrockProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts index 40c66c772e6..cf18f0e334c 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' @@ -18,11 +18,12 @@ async function collectEvents( return events } +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + vi.mock('@/tools', () => ({ - executeTool: vi.fn(async () => ({ - success: true, - output: { ok: true }, - })), + executeTool: mockExecuteTool, })) vi.mock('@/providers/utils', () => ({ @@ -41,6 +42,14 @@ vi.mock('@/providers/utils', () => ({ })) describe('createBedrockStreamingToolLoopStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ + success: true, + output: { ok: true }, + }) + }) + it('emits tool_call_start/end and final text; no invented thinking', async () => { const turns = [ (async function* () { @@ -140,4 +149,199 @@ describe('createBedrockStreamingToolLoopStream', () => { }) ) }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":"https://example.com"}' } }, + }, + } + yield { metadata: { usage: { inputTokens: 11, outputTokens: 4 } } } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + })), + } + const onComplete = vi.fn() + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'call it' }] }], + inferenceConfig: { temperature: 0.7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 11, output: 4, total: 15 } }) + ) + }) + + it('finalizes truncated text when max_tokens is reached without a tool call', async () => { + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'Truncated answer' }, + }, + } + yield { metadata: { usage: { inputTokens: 13, outputTokens: 7 } } } + yield { messageStop: { stopReason: 'max_tokens' } } + })(), + })), + } + const onComplete = vi.fn() + const timeSegments: Array<{ type: string }> = [] + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + } as any, + messages: [{ role: 'user', content: [{ text: 'answer' }] }], + inferenceConfig: { temperature: 0.7, maxTokens: 7 }, + bedrockTools: [], + toolChoice: undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: timeSegments as any, + onComplete, + }) + + await expect(collectEvents(stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Truncated answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Truncated answer', + tokens: { input: 13, output: 7, total: 20 }, + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'max_tokens', + assistantContent: 'Truncated answer', + }) + }) + + it('rejects a max_tokens turn containing a partial tool call', async () => { + const client = { + send: vi.fn(async () => ({ + stream: (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_partial', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":' } }, + }, + } + yield { metadata: { usage: { inputTokens: 13, outputTokens: 7 } } } + yield { messageStop: { stopReason: 'max_tokens' } } + })(), + })), + } + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'answer' }] }], + inferenceConfig: { temperature: 0.7, maxTokens: 7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toMatchObject({ + message: 'Bedrock returned tool use with stop reason max_tokens', + }) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'tooluse_partial', + name: 'http_request', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'tooluse_partial', + name: 'http_request', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts index 5d9c9cff2ee..daae801472c 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -21,13 +21,20 @@ import { } from '@aws-sdk/client-bedrock-runtime' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { MAX_TOOL_ITERATIONS } from '@/providers' -import { checkForForcedToolUsage, generateToolUseId } from '@/providers/bedrock/utils' +import { + checkForForcedToolUsage, + generateToolUseId, + getBedrockStreamError, + supportsToolResultStatus, +} from '@/providers/bedrock/utils' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { isAbortError, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' @@ -61,11 +68,12 @@ function parseToolInput(inputJson: string): Record { if (!inputJson.trim()) return {} try { const parsed = JSON.parse(inputJson) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - return {} + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Tool input must be a JSON object') + } + return parsed as Record + } catch (error) { + throw new Error(`Invalid Bedrock tool input: ${getErrorMessage(error)}`, { cause: error }) } } @@ -88,6 +96,9 @@ async function drainBedrockTurn( let stopReason: string | undefined for await (const event of stream) { + const streamError = getBedrockStreamError(event) + if (streamError) throw streamError + if (event.contentBlockStart) { currentIndex = event.contentBlockStart.contentBlockIndex const start = event.contentBlockStart.start @@ -163,6 +174,15 @@ export function createBedrockStreamingToolLoopStream( } = options const forcedTools = options.forcedTools ?? [] const originalToolChoice = options.toolChoice + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } return new ReadableStream({ async start(controller) { @@ -186,16 +206,44 @@ export function createBedrockStreamingToolLoopStream( const toolCalls: unknown[] = [] const toolResults: Record[] = [] const openToolStarts = new Map() + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { settleOpenTools(controller, openToolStarts, 'cancelled') throw new DOMException('Stream aborted', 'AbortError') } + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + /** + * Bedrock's ToolChoice has no `none`, and `toolConfig` is required + * once the history carries toolUse/toolResult blocks — dropping it to + * force a text-only turn makes Bedrock reject the request outright. + * Keep the tools and relax to `auto`; the `finalSynthesis` guard below + * rejects a tool call the model makes anyway. + */ const toolConfig: ToolConfiguration | undefined = bedrockTools.length - ? { tools: bedrockTools, toolChoice } + ? { tools: bedrockTools, toolChoice: finalSynthesis ? { auto: {} } : toolChoice } : undefined const modelStart = Date.now() @@ -207,10 +255,9 @@ export function createBedrockStreamingToolLoopStream( toolConfig, }) - const streamResponse = await client.send( - command, - request.abortSignal ? { abortSignal: request.abortSignal } : undefined - ) + const streamResponse = await client.send(command, { + abortSignal: loopAbortController.signal, + }) if (!streamResponse.stream) { throw new Error('No stream returned from Bedrock') } @@ -249,19 +296,31 @@ export function createBedrockStreamingToolLoopStream( */ const toolsExecutable = drained.stopReason === 'tool_use' if (drained.toolUses.length > 0 && !toolsExecutable) { - logger.warn('Skipping tool execution for incomplete turn', { - stopReason: drained.stopReason, - toolCount: drained.toolUses.length, - }) settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Bedrock returned tool use with stop reason ${drained.stopReason ?? 'missing'}` + ) + } + if (finalSynthesis && drained.toolUses.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Bedrock returned tool use during final synthesis') } const executableToolUses = toolsExecutable ? drained.toolUses : [] + const cappedTextTurn = drained.stopReason === 'max_tokens' && openToolStarts.size === 0 + if ( + executableToolUses.length === 0 && + drained.stopReason !== 'end_turn' && + drained.stopReason !== 'stop_sequence' && + !cappedTextTurn + ) { + throw new Error( + `Bedrock stream ended with stop reason ${drained.stopReason ?? 'missing'}` + ) + } const turnTag = executableToolUses.length > 0 ? 'intermediate' : 'final' controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (drained.text) { - content = drained.text - } + content = drained.text const assembledToolUses = executableToolUses.map((t) => ({ toolUseId: t.toolUseId, @@ -314,13 +373,16 @@ export function createBedrockStreamingToolLoopStream( assembledToolUses.map(async (toolUse) => { const toolCallStartTime = Date.now() const toolName = toolUse.name || '' - const toolArgs: Record = toolUse.input + const toolArgs = isRecordLike(toolUse.input) ? toolUse.input : undefined const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) try { - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { throw new DOMException('Stream aborted', 'AbortError') } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -356,7 +418,7 @@ export function createBedrockStreamingToolLoopStream( request ) const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, + signal: loopAbortController.signal, }) const toolCallEndTime = Date.now() const status: ToolCallEndStatus = result.success ? 'success' : 'error' @@ -381,11 +443,29 @@ export function createBedrockStreamingToolLoopStream( } } catch (error) { const toolCallEndTime = Date.now() - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing tool call:', { error, toolName }) + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'cancelled', + }) + throw error } - const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' + if (isAbortError(error)) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) + const status: ToolCallEndStatus = 'error' openToolStarts.delete(toolUseId) controller.enqueue({ type: 'tool_call_end', @@ -397,7 +477,7 @@ export function createBedrockStreamingToolLoopStream( toolUse, toolUseId, toolName, - toolArgs, + toolArgs: toolArgs ?? {}, toolParams: {} as Record, result: { success: false as const, @@ -415,13 +495,18 @@ export function createBedrockStreamingToolLoopStream( toolsTime += Date.now() - toolsStartTime - const assistantContent: ContentBlock[] = assembledToolUses.map((toolUse) => ({ - toolUse: { - toolUseId: toolUse.toolUseId, - name: toolUse.name, - input: toolUse.input, - }, - })) + const assistantContent: ContentBlock[] = [ + // Bedrock rejects a blank text block, and a model can emit only + // whitespace before a tool call. + ...(drained.text.trim() ? [{ text: drained.text }] : []), + ...assembledToolUses.map((toolUse) => ({ + toolUse: { + toolUseId: toolUse.toolUseId, + name: toolUse.name, + input: toolUse.input, + }, + })), + ] currentMessages.push({ role: 'assistant' as ConversationRole, content: assistantContent, @@ -441,9 +526,11 @@ export function createBedrockStreamingToolLoopStream( }) let resultContent: unknown - if (result.success && result.output) { - toolResults.push(result.output as Record) - resultContent = result.output + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -465,6 +552,9 @@ export function createBedrockStreamingToolLoopStream( const toolResultBlock: ToolResultBlock = { toolUseId, content: [{ text: JSON.stringify(resultContent) }], + ...(supportsToolResultStatus(modelId) + ? { status: result.success ? 'success' : 'error' } + : {}), } toolResultContent.push({ toolResult: toolResultBlock }) } @@ -491,45 +581,33 @@ export function createBedrockStreamingToolLoopStream( iterationCount += 1 } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + if (!sawFinalTurn) { + throw new Error('Bedrock tool loop ended without a final response') } - const toolCost = sumToolCosts(toolResults) - onComplete({ - content, - tokens, - cost: { - input: costInput, - output: costOutput, - toolCost: toolCost || undefined, - total: costTotal + toolCost, - pricing: latestPricing, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) + reportProgress() controller.close() } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - settleOpenTools(controller, openToolStarts, 'cancelled') - } else { - settleOpenTools(controller, openToolStarts, 'error') - logger.error('Bedrock streaming tool loop failed', { - error: toError(error).message, - }) - } - controller.error(error) + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Bedrock streaming tool loop failed', { + error: toError(cause).message, + }), + }) + } finally { + request.abortSignal?.removeEventListener('abort', abortFromRequest) } }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, }) } diff --git a/apps/sim/providers/bedrock/utils.stream.test.ts b/apps/sim/providers/bedrock/utils.stream.test.ts index d9407b80018..b6b3c6b3d41 100644 --- a/apps/sim/providers/bedrock/utils.stream.test.ts +++ b/apps/sim/providers/bedrock/utils.stream.test.ts @@ -46,4 +46,16 @@ describe('createReadableStreamFromBedrockStream', () => { expect(events.some((e) => e.type === 'tool_call_start')).toBe(false) expect(onComplete).toHaveBeenCalledWith('Done', { inputTokens: 2, outputTokens: 3 }) }) + + it('surfaces Bedrock event-stream exceptions', async () => { + const stream = createReadableStreamFromBedrockStream( + (async function* () { + yield { + modelStreamErrorException: { message: 'Model stream failed' }, + } as any + })() + ) + + await expect(collectEvents(stream)).rejects.toThrow('Model stream failed') + }) }) diff --git a/apps/sim/providers/bedrock/utils.test.ts b/apps/sim/providers/bedrock/utils.test.ts index a667d614122..421e46bcff4 100644 --- a/apps/sim/providers/bedrock/utils.test.ts +++ b/apps/sim/providers/bedrock/utils.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getBedrockInferenceProfileId } from '@/providers/bedrock/utils' +import { getBedrockInferenceProfileId, supportsToolResultStatus } from '@/providers/bedrock/utils' describe('getBedrockInferenceProfileId', () => { it.concurrent('prefixes geo inference profile for models that require it', () => { @@ -44,3 +44,22 @@ describe('getBedrockInferenceProfileId', () => { ).toBe('amazon.titan-text-premier-v1:0') }) }) + +describe('supportsToolResultStatus', () => { + it.concurrent('accepts Claude and Nova through every ID form', () => { + expect(supportsToolResultStatus('bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0')).toBe(true) + expect(supportsToolResultStatus('us.anthropic.claude-opus-4-5-20251101-v1:0')).toBe(true) + expect(supportsToolResultStatus('anthropic.claude-haiku-4-5-20251001-v1:0')).toBe(true) + expect(supportsToolResultStatus('global.amazon.nova-2-lite-v1:0')).toBe(true) + expect(supportsToolResultStatus('bedrock/amazon.nova-micro-v1:0')).toBe(true) + }) + + it.concurrent('rejects every family that returns a ValidationException for it', () => { + expect(supportsToolResultStatus('us.meta.llama4-scout-17b-instruct-v1:0')).toBe(false) + expect(supportsToolResultStatus('bedrock/meta.llama3-3-70b-instruct-v1:0')).toBe(false) + expect(supportsToolResultStatus('mistral.mistral-large-2407-v1:0')).toBe(false) + expect(supportsToolResultStatus('cohere.command-r-plus-v1:0')).toBe(false) + // Titan shares the amazon vendor prefix but is not Nova. + expect(supportsToolResultStatus('bedrock/amazon.titan-text-premier-v1:0')).toBe(false) + }) +}) diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts index 7cdd6b6c125..a8e837b1142 100644 --- a/apps/sim/providers/bedrock/utils.ts +++ b/apps/sim/providers/bedrock/utils.ts @@ -1,5 +1,6 @@ import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { randomFloat } from '@sim/utils/random' import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' @@ -11,6 +12,22 @@ export interface BedrockStreamUsage { outputTokens: number } +/** + * Converts an AWS event-stream exception member into an Error. + */ +export function getBedrockStreamError(event: ConverseStreamOutput): Error | undefined { + const exception = + event.internalServerException ?? + event.modelStreamErrorException ?? + event.validationException ?? + event.throttlingException ?? + event.serviceUnavailableException + if (!exception) return undefined + return new Error(exception.message || getErrorMessage(exception, 'Bedrock stream error'), { + cause: exception, + }) +} + /** * Bedrock ConverseStream → agent-events-v1 for the legacy (non-tool-loop) * streaming path. Text deltas only: tools on this path are never executed, so @@ -25,11 +42,19 @@ export function createReadableStreamFromBedrockStream( let fullContent = '' let inputTokens = 0 let outputTokens = 0 + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { try { - for await (const event of bedrockStream) { + streamIterator = bedrockStream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const event = next.value + const streamError = getBedrockStreamError(event) + if (streamError) throw streamError if (event.contentBlockDelta?.delta?.text) { const text = event.contentBlockDelta.delta.text fullContent += text @@ -40,15 +65,22 @@ export function createReadableStreamFromBedrockStream( } } + if (cancelled) return if (onComplete) { onComplete(fullContent, { inputTokens, outputTokens }) } controller.close() } catch (err) { - controller.error(err) + if (!cancelled) { + controller.error(err) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } @@ -108,6 +140,32 @@ const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ 'cohere.command-r-plus-v1:0', ]) +/** Cross-region inference profile prefixes Bedrock prepends to a base model ID. */ +const GEO_PROFILE_PREFIX_PATTERN = /^(us-gov|us|eu|apac|au|ca|jp|global)\./ + +/** + * Strips Sim's `bedrock/` namespace and any cross-region inference prefix, + * leaving the bare `.` ID that capability checks key off. + */ +function getBedrockBaseModelId(modelId: string): string { + const withoutNamespace = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId + return withoutNamespace.replace(GEO_PROFILE_PREFIX_PATTERN, '') +} + +/** + * Whether the model accepts `status` on a `toolResult` content block. + * + * Only Amazon Nova and Anthropic Claude 3/4 support it; Llama, Mistral, Cohere, + * and Titan reject the whole request with + * `ValidationException: This model doesn't support the status field.` + * + * Source: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolResultBlock.html + */ +export function supportsToolResultStatus(modelId: string): boolean { + const baseModelId = getBedrockBaseModelId(modelId) + return baseModelId.startsWith('anthropic.') || baseModelId.startsWith('amazon.nova') +} + /** * Converts a model ID to the Bedrock inference profile format. * AWS Bedrock requires inference profile IDs (e.g., us.anthropic.claude-...) @@ -121,7 +179,7 @@ const GEO_PROFILE_UNSUPPORTED_MODEL_IDS = new Set([ 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)) { + if (GEO_PROFILE_PREFIX_PATTERN.test(baseModelId)) { return baseModelId } diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index ca9c35a0e80..aaf192a8fe8 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -1,13 +1,17 @@ import { Cerebras } from '@cerebras/cerebras_cloud_sdk' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' 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 { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -231,9 +235,24 @@ export const cerebrasProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -251,6 +270,9 @@ export const cerebrasProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Cerebras):', { error: toError(error).message, @@ -273,25 +295,21 @@ export const cerebrasProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: filteredToolCalls, + reasoningFields: ['reasoning'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', name: toolName, @@ -300,10 +318,12 @@ export const cerebrasProvider: ProviderConfig = { duration: duration, toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -358,7 +378,7 @@ export const cerebrasProvider: ProviderConfig = { } finalPayload.tool_choice = 'none' - const finalResponse = (await client.chat.completions.create( + currentResponse = (await client.chat.completions.create( finalPayload, request.abortSignal ? { signal: request.abortSignal } : undefined )) as CerebrasResponse @@ -376,22 +396,23 @@ export const cerebrasProvider: ProviderConfig = { modelTime += thisModelTime - if (finalResponse.choices[0]?.message?.content) { - content = finalResponse.choices[0].message.content + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.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 + 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, - finalResponse, - finalResponse.choices[0]?.message?.tool_calls, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'cerebras' } ) + iterationCount++ break } @@ -429,16 +450,56 @@ export const cerebrasProvider: ProviderConfig = { } } - if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls + if (iterationCount === MAX_TOOL_ITERATIONS && cappedToolCalls?.length) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + cappedToolCalls, + { model: request.model, provider: 'cerebras' } + ) + + const finalModelStartTime = Date.now() + currentResponse = (await client.chat.completions.create( + { + ...payload, + messages: currentMessages, + tool_choice: 'none', + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + )) as CerebrasResponse + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration + + 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 + } + enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'cerebras' } ) + iterationCount++ } } catch (error) { logger.error('Error in Cerebras tool processing:', { error }) + throw error } const providerEndTime = Date.now() @@ -446,25 +507,8 @@ export const cerebrasProvider: ProviderConfig = { const totalDuration = providerEndTime - providerStartTime if (request.stream) { - logger.info('Using streaming for final Cerebras response after tool processing') - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - 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 toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, @@ -486,8 +530,8 @@ export const cerebrasProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -498,31 +542,11 @@ export const cerebrasProvider: ProviderConfig = { : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromCerebrasStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('Cerebras final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || 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, - } - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -555,6 +579,10 @@ export const cerebrasProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/cost-policy.test.ts b/apps/sim/providers/cost-policy.test.ts new file mode 100644 index 00000000000..5d7ebd9869e --- /dev/null +++ b/apps/sim/providers/cost-policy.test.ts @@ -0,0 +1,293 @@ +/** + * @vitest-environment node + */ +import { envFlagsMockFns, resetEnvFlagsMock } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import type { NormalizedBlockOutput } from '@/executor/types' +import { + applyModelCostPolicy, + applySegmentCostPolicy, + calculateBillableModelCost, + installStreamingCostPolicy, + LIST_PRICE_POLICY, + priceModelUsage, + resolveModelCostPolicy, + resolveProxiedModelCost, + withoutToolCost, +} from '@/providers/cost-policy' +import { calculateCost } from '@/providers/utils' + +/** A model Sim hosts, so its usage is billable when no BYOK key is present. */ +const HOSTED_MODEL = 'claude-opus-4-6' +/** A model reached only with a caller-supplied key, so Sim never bills it. */ +const SELF_KEYED_MODEL = 'llama-3.3-70b-versatile' + +describe('resolveModelCostPolicy', () => { + afterEach(resetEnvFlagsMock) + + it('bills hosted models at the configured multiplier', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + expect(resolveModelCostPolicy(HOSTED_MODEL)).toEqual({ billable: true, multiplier: 2 }) + }) + + it('does not bill when the workspace supplied its own key', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + expect(resolveModelCostPolicy(HOSTED_MODEL, true)).toEqual({ billable: false, multiplier: 0 }) + }) + + it('does not bill models Sim does not host', () => { + expect(resolveModelCostPolicy(SELF_KEYED_MODEL)).toEqual({ billable: false, multiplier: 0 }) + }) +}) + +describe('applyModelCostPolicy', () => { + it('scales model cost by the multiplier and leaves tool cost untouched', () => { + const projected = applyModelCostPolicy( + { input: 1, output: 2, total: 3.5, toolCost: 0.5 }, + { billable: true, multiplier: 2 } + ) + + expect(projected).toMatchObject({ input: 2, output: 4, total: 6.5, toolCost: 0.5 }) + }) + + it('returns the cost unchanged when the multiplier is 1', () => { + const cost = { input: 1, output: 2, total: 3 } + expect(applyModelCostPolicy(cost, { billable: true, multiplier: 1 })).toBe(cost) + }) + + it('zeroes model cost but preserves tool cost when not billable', () => { + const projected = applyModelCostPolicy( + { input: 1, output: 2, total: 3.25, toolCost: 0.25 }, + { billable: false, multiplier: 0 } + ) + + expect(projected).toMatchObject({ input: 0, output: 0, total: 0.25, toolCost: 0.25 }) + }) +}) + +describe('priceModelUsage', () => { + /** Claude Sonnet 5: $2/MTok input, $0.20/MTok cached, $10/MTok output. */ + const PRICED_MODEL = 'claude-sonnet-5' + const PER_TOKEN_INPUT = 2 / 1_000_000 + + it('prices cache reads at the cached rate, not the base rate', () => { + const cached = priceModelUsage( + PRICED_MODEL, + { input: 0, output: 0, cacheRead: 100_000 }, + LIST_PRICE_POLICY + ) + const uncached = priceModelUsage(PRICED_MODEL, { input: 100_000, output: 0 }, LIST_PRICE_POLICY) + + expect(cached.input).toBeCloseTo(uncached.input / 10, 8) + }) + + it('prices each write bucket at its own premium over the base input rate', () => { + const cost = priceModelUsage( + PRICED_MODEL, + { + input: 0, + output: 0, + cacheWrites: [ + { tokens: 100_000, inputRateMultiplier: 1.25 }, + { tokens: 100_000, inputRateMultiplier: 2 }, + ], + }, + LIST_PRICE_POLICY + ) + + expect(cost.input).toBeCloseTo(100_000 * PER_TOKEN_INPUT * 3.25, 8) + }) + + it('matches the plain uncached calculation when there is no cache activity', () => { + const priced = priceModelUsage(PRICED_MODEL, { input: 1000, output: 500 }, LIST_PRICE_POLICY) + const direct = calculateCost(PRICED_MODEL, 1000, 500) + + expect(priced).toMatchObject({ + input: direct.input, + output: direct.output, + total: direct.total, + }) + }) + + it('applies the policy multiplier to every bucket', () => { + const usage = { + input: 1000, + output: 500, + cacheRead: 2000, + cacheWrites: [{ tokens: 1000, inputRateMultiplier: 1.25 }], + } + + const single = priceModelUsage(PRICED_MODEL, usage, { billable: true, multiplier: 1 }) + const tripled = priceModelUsage(PRICED_MODEL, usage, { billable: true, multiplier: 3 }) + + expect(tripled.total).toBeCloseTo(single.total * 3, 8) + }) + + it('charges nothing when the policy is not billable', () => { + const cost = priceModelUsage( + PRICED_MODEL, + { input: 1000, output: 500, cacheRead: 5000 }, + { billable: false, multiplier: 0 } + ) + + expect(cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) + + /** + * The regression this guards: Anthropic reports `input_tokens` already + * excluding cache tokens while OpenAI and Gemini report a cached subset of + * the prompt total. A normalization mistake in either adapter shows up as + * two different prices for one real token split. + */ + it('prices the same real token split identically across opposite wire shapes', () => { + const anthropicShaped = priceModelUsage( + PRICED_MODEL, + { input: 1000, output: 500, cacheRead: 4000 }, + LIST_PRICE_POLICY + ) + + const openAIPromptTotal = 5000 + const openAICached = 4000 + const openAIShaped = priceModelUsage( + PRICED_MODEL, + { + input: openAIPromptTotal - openAICached, + output: 500, + cacheRead: openAICached, + }, + LIST_PRICE_POLICY + ) + + expect(openAIShaped).toEqual(anthropicShaped) + }) +}) + +describe('withoutToolCost', () => { + it('removes a provider-folded tool cost and rebases the total', () => { + expect(withoutToolCost({ input: 1, output: 2, total: 3.5, toolCost: 0.5 })).toEqual({ + input: 1, + output: 2, + total: 3, + }) + }) + + it('leaves a model-only cost untouched', () => { + const cost = { input: 1, output: 2, total: 3 } + expect(withoutToolCost(cost)).toBe(cost) + }) +}) + +describe('calculateBillableModelCost', () => { + afterEach(resetEnvFlagsMock) + + it('matches the streaming projection for the same tokens', () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(3) + + const nonStreaming = calculateBillableModelCost(HOSTED_MODEL, 1000, 500) + const unscaled = calculateBillableModelCost(HOSTED_MODEL, 1000, 500) + // The streaming path projects a provider cost computed without the + // multiplier, so remove it before comparing against the direct calculation. + const providerCost = { + input: unscaled.input / 3, + output: unscaled.output / 3, + total: unscaled.total / 3, + } + const streaming = applyModelCostPolicy(providerCost, resolveModelCostPolicy(HOSTED_MODEL)) + + expect(streaming.total).toBeCloseTo(nonStreaming.total, 8) + }) + + it('returns a zero cost carrying pricing for models Sim does not host', () => { + const cost = calculateBillableModelCost(SELF_KEYED_MODEL, 1000, 500) + expect(cost).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(cost.pricing).toBeDefined() + }) +}) + +describe('installStreamingCostPolicy', () => { + afterEach(resetEnvFlagsMock) + + it('applies the multiplier to cost written after the stream starts', () => { + const output = { cost: { input: 0, output: 0, total: 0 } } as NormalizedBlockOutput + installStreamingCostPolicy(output, { billable: true, multiplier: 2 }) + + output.cost = { input: 1, output: 2, total: 3 } + + expect(output.cost).toMatchObject({ input: 2, output: 4, total: 6 }) + }) + + it('keeps tool cost from a settled stream that wrote its cost before the policy was installed', () => { + const output = { + cost: { input: 1, output: 2, total: 3.75, toolCost: 0.75 }, + } as NormalizedBlockOutput + + installStreamingCostPolicy(output, { billable: false, multiplier: 0 }) + + expect(output.cost).toMatchObject({ input: 0, output: 0, total: 0.75, toolCost: 0.75 }) + }) + + it('zeroes model cost written by a provider for a model Sim does not host', () => { + const output = { cost: { input: 0, output: 0, total: 0 } } as NormalizedBlockOutput + installStreamingCostPolicy(output, resolveModelCostPolicy(SELF_KEYED_MODEL)) + + output.cost = { input: 0.5, output: 1.5, total: 2 } + + expect(output.cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) +}) + +describe('applySegmentCostPolicy', () => { + it('zeroes model segments only when the call is not billable', () => { + const segments = [ + { type: 'model', cost: { input: 1, output: 2, total: 3 } }, + { type: 'tool', cost: { total: 0.01 } }, + ] + + applySegmentCostPolicy(segments, { billable: true, multiplier: 1 }) + expect(segments[0].cost).toMatchObject({ total: 3 }) + + applySegmentCostPolicy(segments, { billable: false, multiplier: 0 }) + expect(segments[0].cost).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(segments[1].cost).toMatchObject({ total: 0.01 }) + }) +}) + +describe('resolveProxiedModelCost', () => { + it('passes through the proxy cost without recomputing it', () => { + const pricing = { input: 5, output: 25, updatedAt: '2026-04-01' } + expect( + resolveProxiedModelCost({ input: 1, output: 2, total: 3, toolCost: 0.5, pricing }) + ).toEqual({ + input: 1, + output: 2, + total: 3, + toolCost: 0.5, + pricing, + }) + }) + + it('treats a missing or malformed cost as no billable usage', () => { + expect(resolveProxiedModelCost(undefined)).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(resolveProxiedModelCost(0.003)).toMatchObject({ input: 0, output: 0, total: 0 }) + expect(resolveProxiedModelCost({ input: Number.NaN, output: 1, total: 1 })).toMatchObject({ + input: 0, + output: 1, + total: 1, + }) + expect( + resolveProxiedModelCost({ input: 1, output: 1, total: Number.POSITIVE_INFINITY }) + ).toMatchObject({ total: 0 }) + expect(resolveProxiedModelCost({ input: 1, output: 1, total: '2' })).toMatchObject({ total: 0 }) + }) + + it('never lets a corrupt negative figure credit the run', () => { + expect(resolveProxiedModelCost({ input: -5, output: -1, total: -6 })).toMatchObject({ + input: 0, + output: 0, + total: 0, + }) + expect(resolveProxiedModelCost({ input: 1, output: 1, total: 2, toolCost: -3 })).toMatchObject({ + toolCost: 0, + }) + }) +}) diff --git a/apps/sim/providers/cost-policy.ts b/apps/sim/providers/cost-policy.ts new file mode 100644 index 00000000000..d89b978debd --- /dev/null +++ b/apps/sim/providers/cost-policy.ts @@ -0,0 +1,303 @@ +import { getCostMultiplier } from '@/lib/core/config/env-flags' +import type { NormalizedBlockOutput } from '@/executor/types' +import type { ModelPricing } from '@/providers/types' +import { calculateCost, shouldBillModelUsage } from '@/providers/utils' + +/** + * Single source of truth for whether a model response is charged to the caller + * and at what margin. + * + * Two concerns are deliberately separated: + * - Providers own token → USD *pricing*; they alone know cache tiers, reasoning + * tokens, and per-turn accumulation. + * - This module owns billing *policy*: whether Sim supplied the credentials at + * all, and the production margin multiplier. + * + * Every surface that turns tokens into a charge — the streaming and + * non-streaming provider paths, Router, Evaluator, and Pi — resolves policy + * here, so a model costs the same no matter which path produced it. + */ + +/** Cost shape written onto block output. Mirrors `BlockCost`. */ +export interface ModelCost { + input: number + output: number + total: number + toolCost?: number + pricing?: ModelPricing +} + +/** Cost that always carries pricing, as `ProviderResponse['cost']` requires. */ +export type PricedModelCost = ModelCost & { pricing: ModelPricing } + +export interface ModelCostPolicy { + /** True when Sim supplied the credentials and must charge for the tokens. */ + billable: boolean + /** Margin applied to billable model cost; `0` when the call is not billable. */ + multiplier: number +} + +/** + * Prices at the vendor's list rate with no margin. + * + * Provider adapters use this: they price tokens the vendor charged, and the + * central layer applies the billability gate and margin afterwards (via + * {@link applyModelCostPolicy} or {@link installStreamingCostPolicy}). A + * provider applying the policy itself would double-count the multiplier. + */ +export const LIST_PRICE_POLICY: ModelCostPolicy = Object.freeze({ + billable: true, + multiplier: 1, +}) + +/** Pricing echoed on a zero cost so consumers can tell "not billed" from "unpriced". */ +const NOT_BILLED_PRICING: ModelPricing = Object.freeze({ + input: 0, + output: 0, + updatedAt: new Date(0).toISOString(), +}) + +function roundCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Resolves the billing policy for a model response. + * + * A response is billable only when the model is one Sim hosts (so Sim's own key + * paid the provider) and the workspace did not supply its own key. + */ +export function resolveModelCostPolicy(model: string, isBYOK = false): ModelCostPolicy { + const billable = !isBYOK && shouldBillModelUsage(model) + return { billable, multiplier: billable ? getCostMultiplier() : 0 } +} + +/** Zero model cost, preserving any tool cost already charged at the tool's own rate. */ +export function notBilledCost(toolCost = 0): PricedModelCost { + return { + input: 0, + output: 0, + total: roundCost(toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: NOT_BILLED_PRICING, + } +} + +/** + * Projects a model cost through the billing policy. + * + * Tool cost passes through untouched — it is already billed at the tool's own + * rate by the tool layer and must not pick up the model margin. + */ +export function applyModelCostPolicy( + cost: ModelCost | undefined, + policy: ModelCostPolicy +): ModelCost { + const toolCost = cost?.toolCost ?? 0 + + if (!cost || !policy.billable) { + return notBilledCost(toolCost) + } + + if (policy.multiplier === 1) { + return cost + } + + const modelTotal = cost.total - toolCost + + return { + ...cost, + input: roundCost(cost.input * policy.multiplier), + output: roundCost(cost.output * policy.multiplier), + total: roundCost(modelTotal * policy.multiplier + toolCost), + } +} + +/** A cache write bucket and its premium over the model's base input rate. */ +export interface CacheWriteUsage { + tokens: number + /** Anthropic: 1.25 (5m) or 2 (1h). OpenAI: 1.25 on GPT-5.6+, free before it. */ + inputRateMultiplier: number +} + +/** + * Normalized token usage for one model call. + * + * Providers report cache usage in incompatible shapes — Anthropic's + * `input_tokens` already excludes cache tokens, while OpenAI's `cached_tokens` + * and Gemini's `cachedContentTokenCount` are subsets of their prompt totals. + * Each provider adapter resolves that difference before building this; nothing + * downstream branches on provider again. + */ +export interface ModelUsage { + /** Tokens billed at the base input rate. Always EXCLUDES cache reads/writes. */ + input: number + output: number + /** Tokens served from the provider's cache, billed at the cachedInput rate. */ + cacheRead?: number + /** Tokens written to the cache, each bucket at its own premium. */ + cacheWrites?: CacheWriteUsage[] +} + +/** + * The single cache-aware pricing function. + * + * Every surface that turns tokens into a charge routes through here, so a cache + * hit is priced identically whether it came from Anthropic, OpenAI, Gemini, or + * an OpenAI-compatible vendor. Callers must not pre-apply the policy multiplier + * or the cached rate themselves. + * + * Token counts are validated by the provider adapter that builds the + * {@link ModelUsage}, which is the only layer that knows the vendor's shape and + * can enforce that cache buckets are a subset of the prompt total. This function + * re-validates nothing. + */ +export function priceModelUsage( + model: string, + usage: ModelUsage, + policy: ModelCostPolicy +): PricedModelCost { + if (!policy.billable) { + return notBilledCost() + } + + const multiplier = policy.multiplier + const base = calculateCost(model, usage.input, usage.output, false, multiplier, multiplier) + + const cacheRead = usage.cacheRead ?? 0 + const read = cacheRead > 0 ? calculateCost(model, cacheRead, 0, true, multiplier, 0) : undefined + + let writeInputCost = 0 + for (const write of usage.cacheWrites ?? []) { + if (write.tokens <= 0) continue + writeInputCost += calculateCost( + model, + write.tokens, + 0, + false, + multiplier * write.inputRateMultiplier, + 0 + ).input + } + + const input = roundCost(base.input + (read?.input ?? 0) + writeInputCost) + const output = base.output + + return { + input, + output, + total: roundCost(input + output), + pricing: base.pricing, + } +} + +/** + * Prices tokens for a model and applies the billing policy in one step. Use + * wherever a caller holds raw token counts and needs the charge Sim records. + * + * Cache-free by design: a caller that has cache usage also knows its tiers, so + * it builds a {@link ModelUsage} and calls {@link priceModelUsage} directly. + */ +export function calculateBillableModelCost( + model: string, + promptTokens = 0, + completionTokens = 0, + options: { isBYOK?: boolean } = {} +): PricedModelCost { + return priceModelUsage( + model, + { input: promptTokens, output: completionTokens }, + resolveModelCostPolicy(model, options.isBYOK) + ) +} + +/** + * Drops a tool cost a provider folded into its own total. + * + * `executeProviderRequest` re-derives tool cost from `response.toolResults` and + * adds it after the policy is applied, so a provider that already accounted for + * it would otherwise have it counted twice. + */ +export function withoutToolCost(cost: ModelCost): ModelCost { + if (cost.toolCost === undefined) return cost + + const { toolCost: _toolCost, ...model } = cost + return { ...model, total: roundCost(model.input + model.output) } +} + +/** Rejects NaN, Infinity, and negatives — a negative would credit the run. */ +function billableAmount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +/** + * Reads the cost off a JSON response returned by the `/api/providers` proxy. + * + * The proxy already resolved key provenance and applied the policy, so its cost + * is authoritative and must never be recomputed from token counts — the caller + * cannot see whether a workspace BYOK key paid for the call. A response with no + * usable cost carried no billable usage. + */ +export function resolveProxiedModelCost(cost: unknown): ModelCost { + if (!cost || typeof cost !== 'object') { + return notBilledCost() + } + + const { input, output, total, toolCost, pricing } = cost as Partial + return { + input: billableAmount(input), + output: billableAmount(output), + total: billableAmount(total), + ...(toolCost === undefined ? {} : { toolCost: billableAmount(toolCost) }), + // Kept so downstream cost estimators can tell a priced zero from an + // unpriced block and leave the charge alone. + ...(pricing ? { pricing } : {}), + } +} + +/** + * Applies the billing policy to a streaming block output. + * + * Streaming providers write their final cost from inside the stream drain, long + * after `executeRequest` returns, so the policy is installed as an accessor + * rather than applied to a value. A cost already present — providers that + * settle their tool loop before returning — becomes the initial value, so its + * tool cost survives. + */ +export function installStreamingCostPolicy( + output: NormalizedBlockOutput, + policy: ModelCostPolicy +): void { + let raw = output.cost as ModelCost | undefined + + Object.defineProperty(output, 'cost', { + get: () => applyModelCostPolicy(raw, policy), + set: (value: ModelCost | undefined) => { + raw = value + }, + configurable: true, + enumerable: true, + }) +} + +/** + * Zeroes per-segment model cost on already-populated time segments when the + * call is not billable. + * + * Segment costs come from the trace enrichers, which price tokens without + * knowing key provenance. Block-level cost stays authoritative for billing — + * `calculateCostSummary` skips model children of a span that has its own cost — + * so this only keeps the displayed breakdown from contradicting it. + */ +export function applySegmentCostPolicy( + segments: Array<{ type?: string; cost?: { input?: number; output?: number; total?: number } }>, + policy: ModelCostPolicy +): void { + if (policy.billable) return + + for (const segment of segments) { + if (segment.type === 'model' && segment.cost) { + segment.cost = { input: 0, output: 0, total: 0 } + } + } +} diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts index 1ccaa61b72d..ff5cf829824 100644 --- a/apps/sim/providers/deepseek/index.test.ts +++ b/apps/sim/providers/deepseek/index.test.ts @@ -2,10 +2,13 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import type { ProviderRequest } from '@/providers/types' -const { mockCreate } = vi.hoisted(() => ({ +const { mockCreate, mockExecuteTool, mockPrepareToolsWithUsageControl } = vi.hoisted(() => ({ mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockPrepareToolsWithUsageControl: vi.fn(), })) vi.mock('openai', () => ({ @@ -46,17 +49,12 @@ vi.mock('@/providers/trace-enrichment', () => ({ 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: [], - hasFilteredTools: false, - })), + prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, sumToolCosts: vi.fn(() => 0), trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) -vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { deepseekProvider } from '@/providers/deepseek/index' @@ -72,6 +70,14 @@ function request(overrides: Partial = {}): ProviderRequest { describe('deepseekProvider thinking payload', () => { beforeEach(() => { mockCreate.mockReset() + mockExecuteTool.mockReset() + mockPrepareToolsWithUsageControl.mockReset() + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }) mockCreate.mockResolvedValue({ choices: [{ message: { content: 'ok', tool_calls: [] } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, @@ -96,4 +102,47 @@ describe('deepseekProvider thinking payload', () => { const payload = mockCreate.mock.calls[0][0] expect(payload.thinking).toBeUndefined() }) + + it('selects the live tool loop without a caller flag', async () => { + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [ + { + type: 'function', + function: { name: 'lookup', description: 'Lookup', parameters: {} }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + vi.mocked(createOpenAICompatStreamingToolLoopStream).mockReturnValue( + new ReadableStream() as never + ) + + const result = (await deepseekProvider.executeRequest( + request({ + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + )) as unknown as { + createStream: (handles: { + output: { content?: string } + finalizeTiming: () => void + }) => ReadableStream + } + + const output: { content?: string } = {} + result.createStream({ output, finalizeTiming: vi.fn() }) + + expect(createOpenAICompatStreamingToolLoopStream).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 198fbf2d2c5..93bf998a270 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' @@ -8,6 +9,7 @@ import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/uti import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -21,7 +23,6 @@ import { calculateCost, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -49,7 +50,7 @@ export const deepseekProvider: ProviderConfig = { try { const deepseek = new OpenAI({ apiKey: request.apiKey, - baseURL: 'https://api.deepseek.com/v1', + baseURL: 'https://api.deepseek.com', }) const allMessages = [] @@ -90,11 +91,23 @@ export const deepseekProvider: ProviderConfig = { * on reasoner). The API default is enabled, so 'none' must explicitly send * `disabled`; unset sends nothing to preserve the legacy request shape. */ + const usesThinkingMode = + request.thinkingLevel !== undefined + ? request.thinkingLevel !== 'none' + : request.model !== 'deepseek-chat' if (request.thinkingLevel && request.thinkingLevel !== 'none') { payload.thinking = { type: 'enabled' } } else if (request.thinkingLevel === 'none') { payload.thinking = { type: 'disabled' } } + if (request.reasoningEffort && !['auto', 'none'].includes(request.reasoningEffort)) { + payload.reasoning_effort = + request.reasoningEffort === 'xhigh' + ? 'max' + : request.reasoningEffort === 'low' || request.reasoningEffort === 'medium' + ? 'high' + : request.reasoningEffort + } let preparedTools: ReturnType | null = null @@ -102,30 +115,32 @@ export const deepseekProvider: ProviderConfig = { preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'deepseek') const { tools: filteredTools, toolChoice } = preparedTools - if (filteredTools?.length && toolChoice) { + if (filteredTools?.length) { payload.tools = filteredTools - payload.tool_choice = toolChoice + if (toolChoice && !usesThinkingMode) { + 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', + !toolChoice || usesThinkingMode + ? 'provider-default' + : 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 shouldStreamToolCalls = request.streamToolCalls ?? false - - if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + if (request.stream && payload.tools?.length) { logger.info('Using streaming tool loop for DeepSeek request') const timeSegments: TimeSegment[] = [] @@ -156,7 +171,14 @@ export const deepseekProvider: ProviderConfig = { // double-cast-allowed: formatMessagesForProvider returns loosely-typed provider messages that are wire-compatible with the OpenAI chat.completions message params the shared loop expects formattedMessages as unknown as OpenAI.Chat.Completions.ChatCompletionMessageParam[], createStream: async (params, options) => - deepseek.chat.completions.create({ ...params, stream: true }, options), + deepseek.chat.completions.create( + { + ...params, + stream: true, + stream_options: { include_usage: true }, + }, + options + ), logger, timeSegments, forcedTools, @@ -184,13 +206,14 @@ export const deepseekProvider: ProviderConfig = { }) } - if (request.stream && (!tools || tools.length === 0)) { + if (request.stream && !payload.tools?.length) { logger.info('Using streaming response for DeepSeek request (no tools)') const streamResponse = await deepseek.chat.completions.create( { ...payload, stream: true, + stream_options: { include_usage: true }, }, request.abortSignal ? { signal: request.abortSignal } : undefined ) @@ -204,7 +227,7 @@ export const deepseekProvider: ProviderConfig = { initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => + createStream: ({ output, finalizeTiming }) => createReadableStreamFromDeepseekStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects streamResponse as unknown as AsyncIterable, @@ -233,6 +256,7 @@ export const deepseekProvider: ProviderConfig = { segment.thinkingContent = thinking } } + finalizeTiming() } ), }) @@ -324,10 +348,25 @@ export const deepseekProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -345,6 +384,9 @@ export const deepseekProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -364,12 +406,12 @@ export const deepseekProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) const assistantMessage = currentResponse.choices[0]?.message const assistantHistory: { role: string - content: string | null + content: string tool_calls: Array<{ id: string type: string @@ -378,7 +420,7 @@ export const deepseekProvider: ProviderConfig = { reasoning_content?: string } = { role: 'assistant', - content: null, + content: assistantMessage?.content ?? '', tool_calls: toolCallsInResponse.map((tc) => ({ id: tc.id, type: 'function', @@ -403,11 +445,9 @@ export const deepseekProvider: ProviderConfig = { } currentMessages.push(assistantHistory) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -418,10 +458,12 @@ export const deepseekProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -535,107 +577,13 @@ export const deepseekProvider: ProviderConfig = { } } catch (error) { logger.error('Error in Deepseek request:', { error }) + throw 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') - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path; with `auto` a - * model can re-call and end the stream with no text. - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromDeepseekStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (streamedContent, usage, thinking) => { - if (!streamedContent && content) { - logger.warn('DeepSeek final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || 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, - } - - if (thinking) { - const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - } - ), - }) - - return streamingResult - } - return { content, model: request.model, @@ -663,6 +611,9 @@ export const deepseekProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/fireworks/index.test.ts b/apps/sim/providers/fireworks/index.test.ts index 8c38a5b7303..f20d8568630 100644 --- a/apps/sim/providers/fireworks/index.test.ts +++ b/apps/sim/providers/fireworks/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/fireworks/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,16 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = ( + assistant: { content?: string | null; reasoning_content?: string } = {} +) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning_content !== undefined + ? { reasoning_content: assistant.reasoning_content } + : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -218,15 +226,54 @@ describe('fireworksProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Fireworks assistant content and reasoning_content on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await fireworksProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await fireworksProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await fireworksProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index f30a58768bb..0ec540a8c2b 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { supportsNativeStructuredOutputs, } from '@/providers/fireworks/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -263,10 +267,25 @@ export const fireworksProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -284,6 +303,9 @@ export const fireworksProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Fireworks):', { error: toError(error).message, @@ -306,26 +328,21 @@ export const fireworksProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -336,10 +353,12 @@ export const fireworksProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -417,85 +436,61 @@ export const fireworksProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'fireworks' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'fireworks', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - 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, - streamFormat: 'agent-events-v1', - 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, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - 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, - } - }), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'fireworks' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -551,6 +546,45 @@ export const fireworksProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -568,7 +602,7 @@ export const fireworksProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -591,6 +625,10 @@ export const fireworksProvider: ProviderConfig = { } logger.error('Error in Fireworks request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/gemini/core.cost.test.ts b/apps/sim/providers/gemini/core.cost.test.ts new file mode 100644 index 00000000000..96d0c660300 --- /dev/null +++ b/apps/sim/providers/gemini/core.cost.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeGeminiRequest } from '@/providers/gemini/core' +import type { ProviderResponse } from '@/providers/types' +import { calculateCost } from '@/providers/utils' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers', () => ({ + MAX_TOOL_ITERATIONS: 5, +})) + +/** input 0.30/M, cachedInput 0.03/M, output 2.50/M. */ +const MODEL = 'gemini-2.5-flash' + +function textTurn(usageMetadata: Record) { + return { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata, + } +} + +function toolTurn(usageMetadata: Record) { + return { + candidates: [ + { + content: { role: 'model', parts: [{ functionCall: { name: 'lookup', args: {} } }] }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata, + } +} + +async function run(generateContent: ReturnType, withTools = false) { + return (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: MODEL, + providerType: 'google', + request: { + model: MODEL, + apiKey: 'test-key', + messages: [{ role: 'user', content: 'Look this up' }], + ...(withTools + ? { + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } + : {}), + }, + })) as ProviderResponse +} + +describe('Gemini block cost with implicit prompt caching', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + }) + + it('bills cached prompt tokens at the discounted rate, not the full input rate', async () => { + const generateContent = vi.fn().mockResolvedValue( + textTurn({ + promptTokenCount: 100_000, + cachedContentTokenCount: 80_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ) + + const response = await run(generateContent) + + expect(response.tokens).toEqual({ + input: 20_000, + output: 1_000, + cacheRead: 80_000, + total: 101_000, + }) + expect(response.cost?.input).toBeCloseTo(0.0084, 10) + expect(response.cost?.output).toBeCloseTo(0.0025, 10) + expect(response.cost?.total).toBeCloseTo(0.0109, 10) + + const cacheBlind = calculateCost(MODEL, 100_000, 1_000) + expect(cacheBlind.total).toBeCloseTo(0.0325, 10) + expect(response.cost?.total).toBeLessThan(cacheBlind.total) + }) + + it('accumulates the cached and uncached buckets separately across tool-loop turns', async () => { + const generateContent = vi + .fn() + .mockResolvedValueOnce( + toolTurn({ + promptTokenCount: 10_000, + cachedContentTokenCount: 6_000, + candidatesTokenCount: 100, + totalTokenCount: 10_100, + }) + ) + .mockResolvedValueOnce( + textTurn({ + promptTokenCount: 12_000, + cachedContentTokenCount: 9_000, + candidatesTokenCount: 200, + totalTokenCount: 12_200, + }) + ) + + const response = await run(generateContent, true) + + expect(generateContent).toHaveBeenCalledTimes(2) + expect(response.tokens).toEqual({ + input: 7_000, + output: 300, + cacheRead: 15_000, + total: 22_300, + }) + expect(response.cost?.input).toBeCloseTo(0.00255, 10) + expect(response.cost?.output).toBeCloseTo(0.00075, 10) + expect(response.cost?.total).toBeCloseTo(0.0033, 10) + }) + + it('matches plain calculateCost when the response reports no cache hit', async () => { + const generateContent = vi.fn().mockResolvedValue( + textTurn({ + promptTokenCount: 100_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ) + + const response = await run(generateContent) + + expect(response.tokens).toEqual({ + input: 100_000, + output: 1_000, + cacheRead: 0, + total: 101_000, + }) + expect(response.cost).toEqual(calculateCost(MODEL, 100_000, 1_000)) + }) +}) diff --git a/apps/sim/providers/gemini/core.streaming.test.ts b/apps/sim/providers/gemini/core.streaming.test.ts new file mode 100644 index 00000000000..c2af71ee05a --- /dev/null +++ b/apps/sim/providers/gemini/core.streaming.test.ts @@ -0,0 +1,324 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeGeminiRequest } from '@/providers/gemini/core' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers', () => ({ + MAX_TOOL_ITERATIONS: 1, +})) + +describe('executeGeminiRequest settled stream projection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps the required Gemini 2 schema extraction but does not regenerate for streaming', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: {} } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: 'unformatted answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: '{"value":"formatted"}' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 3, + candidatesTokenCount: 3, + totalTokenCount: 6, + }, + }) + const generateContentStream = vi.fn() + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream } } as never, + model: 'gemini-2.5-flash', + providerType: 'google', + request: { + model: 'gemini-2.5-flash', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + }, + }, + })) as StreamingExecution + + expect(generateContent).toHaveBeenCalledTimes(3) + expect(generateContentStream).not.toHaveBeenCalled() + expect(generateContent.mock.calls[2][0].config).toMatchObject({ + tools: undefined, + toolConfig: undefined, + responseMimeType: 'application/json', + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: '{"value":"formatted"}', turn: 'final' }, + }) + expect(result.execution.output.content).toBe('{"value":"formatted"}') + }) + + it('runs one schema synthesis after the tool-batch cap without executing over-cap calls', async () => { + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'tool result' } }) + + const responseSchema = { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + } + const generateContent = vi + .fn() + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: { batch: 1 } } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: { batch: 1 } }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: { batch: 2 } } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: { batch: 2 } }], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + }) + .mockResolvedValueOnce({ + candidates: [ + { + content: { role: 'model', parts: [{ text: '{"value":"capped"}' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 3, + candidatesTokenCount: 3, + totalTokenCount: 6, + }, + }) + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream: vi.fn() } } as never, + model: 'gemini-2.5-flash', + providerType: 'google', + request: { + model: 'gemini-2.5-flash', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Keep looking this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + parameters: { + type: 'object', + properties: { batch: { type: 'number' } }, + required: ['batch'], + }, + }, + ], + responseFormat: { + name: 'answer', + schema: responseSchema, + }, + }, + })) as StreamingExecution + + expect(generateContent).toHaveBeenCalledTimes(3) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect(mockExecuteTool).toHaveBeenCalledWith( + 'lookup', + expect.objectContaining({ batch: 1 }), + expect.any(Object) + ) + expect(generateContent.mock.calls[2][0].config).toMatchObject({ + tools: undefined, + toolConfig: undefined, + responseMimeType: 'application/json', + responseSchema, + }) + + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: '{"value":"capped"}', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + expect(result.execution.output.content).toBe('{"value":"capped"}') + expect(result.execution.output.tokens).toEqual({ + input: 6, + output: 6, + cacheRead: 0, + total: 12, + }) + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + }) + + it.each(['google', 'vertex'] as const)( + 'uses the live loop for %s streaming tool requests without a caller flag', + async (providerType) => { + mockExecuteTool.mockResolvedValue({ success: true, output: false }) + + const toolTurn = { + candidates: [ + { + content: { + role: 'model', + parts: [{ functionCall: { name: 'lookup', args: {} } }], + }, + finishReason: 'STOP', + }, + ], + functionCalls: [{ name: 'lookup', args: {} }], + usageMetadata: { + promptTokenCount: 1, + candidatesTokenCount: 1, + totalTokenCount: 2, + }, + } + const answerTurn = { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'live answer' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 2, + totalTokenCount: 4, + }, + } + const generateContent = vi.fn() + const generateContentStream = vi + .fn() + .mockResolvedValueOnce( + (async function* () { + yield toolTurn + })() + ) + .mockResolvedValueOnce( + (async function* () { + yield answerTurn + })() + ) + + const result = (await executeGeminiRequest({ + ai: { models: { generateContent, generateContentStream } } as never, + model: 'gemini-3-flash-preview', + providerType, + request: { + model: 'gemini-3-flash-preview', + apiKey: 'test-key', + stream: true, + messages: [{ role: 'user', content: 'Look this up' }], + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }, + })) as StreamingExecution + + const events: unknown[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + + expect(generateContent).not.toHaveBeenCalled() + expect(generateContentStream).toHaveBeenCalledTimes(2) + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(result.execution.output.content).toBe('live answer') + } + ) +}) diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index e634b6d0768..2a9177af5f2 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -13,9 +13,11 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' +import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' import { checkForForcedToolUsage, cleanSchemaForGemini, @@ -29,7 +31,9 @@ import { mapToThinkingLevel, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError } from '@/providers/streaming-tool-loop-shared' import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { @@ -39,7 +43,6 @@ import type { TimeSegment, } from '@/providers/types' import { - calculateCost, isDeepResearchModel, isGemini3Model, prepareToolExecution, @@ -60,20 +63,12 @@ function createInitialState( model: string, toolConfig: ToolConfig | undefined ): ExecutionState { - const initialCost = calculateCost( - model, - initialUsage.promptTokenCount, - initialUsage.candidatesTokenCount - ) + const split = splitGeminiUsage(initialUsage) return { contents, - tokens: { - input: initialUsage.promptTokenCount, - output: initialUsage.candidatesTokenCount, - total: initialUsage.totalTokenCount, - }, - cost: initialCost, + tokens: { ...split, total: initialUsage.totalTokenCount }, + cost: priceGeminiTokens(model, split), toolCalls: [], toolResults: [], iterationCount: 0, @@ -114,7 +109,7 @@ async function executeToolCallsBatch( const toolCallStartTime = Date.now() const functionCall = part.functionCall! const toolName = functionCall.name ?? '' - const args = (functionCall.args ?? {}) as Record + const args = isRecordLike(functionCall.args) ? functionCall.args : undefined const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -133,6 +128,10 @@ async function executeToolCallsBatch( } try { + if (!args) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } + const { toolParams, executionParams } = prepareToolExecution(tool, args, request) const result = await executeTool(toolName, executionParams, { signal: request.abortSignal, @@ -157,6 +156,10 @@ async function executeToolCallsBatch( duration, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + const toolCallEndTime = Date.now() logger.error('Error processing function call:', { error: toError(error).message, @@ -166,7 +169,7 @@ async function executeToolCallsBatch( success: false, part, toolName, - args, + args: args ?? {}, resultContent: { error: true, message: getErrorMessage(error, 'Tool execution failed'), @@ -221,8 +224,8 @@ async function executeToolCallsBatch( result: r.resultContent, }) - if (r.success && r.result?.output) { - newToolResults.push(r.result.output as Record) + if (r.success && isRecordLike(r.result?.output)) { + newToolResults.push(r.result.output) } newTimeSegments.push({ @@ -272,14 +275,16 @@ function updateStateWithResponse( endTime: number ): ExecutionState { const usage = convertUsageMetadata(response.usageMetadata) - const cost = calculateCost(model, usage.promptTokenCount, usage.candidatesTokenCount) + const split = splitGeminiUsage(usage) + const cost = priceGeminiTokens(model, split) const duration = endTime - startTime return { ...state, tokens: { - input: state.tokens.input + usage.promptTokenCount, - output: state.tokens.output + usage.candidatesTokenCount, + input: state.tokens.input + split.input, + output: state.tokens.output + split.output, + cacheRead: state.tokens.cacheRead + split.cacheRead, total: state.tokens.total + usage.totalTokenCount, }, cost: { @@ -299,7 +304,6 @@ function updateStateWithResponse( duration, }, ], - iterationCount: state.iterationCount + 1, } } @@ -355,7 +359,7 @@ function createStreamingResult( output: { content: '', model: '', - tokens: state?.tokens ?? { input: 0, output: 0, total: 0 }, + tokens: state?.tokens ?? { input: 0, output: 0, cacheRead: 0, total: 0 }, toolCalls: state?.toolCalls.length ? { list: state.toolCalls, count: state.toolCalls.length } : undefined, @@ -367,7 +371,9 @@ function createStreamingResult( modelTime: state?.modelTime ?? firstResponseTime, toolsTime: state?.toolsTime ?? 0, firstResponseTime, - iterations: (state?.iterationCount ?? 0) + 1, + iterations: state + ? state.timeSegments.filter((segment) => segment.type === 'model').length + : 1, timeSegments: state?.timeSegments ?? [ { type: 'model', @@ -488,22 +494,30 @@ function extractTextFromInteractionOutputs(outputs: Interactions.Interaction['ou return textParts.join('\n\n') } +/** Token usage for one deep research interaction. */ +interface DeepResearchUsage { + inputTokens: number + outputTokens: number + reasoningTokens: number + cachedTokens: number + totalTokens: number +} + /** * Extracts token usage from an Interaction's Usage object. * The Interactions API provides total_input_tokens, total_output_tokens, total_tokens, - * and total_reasoning_tokens (for thinking models). + * total_cached_tokens, and total_reasoning_tokens (for thinking models). * * Also handles the raw API field name total_thought_tokens which the SDK may * map to total_reasoning_tokens. + * + * The Interactions API supports implicit caching, and `total_cached_tokens` is a + * subset of `total_input_tokens` there just as `cachedContentTokenCount` is of + * `promptTokenCount` on generateContent. */ -function extractInteractionUsage(usage: Interactions.Usage | undefined): { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number -} { +function extractInteractionUsage(usage: Interactions.Usage | undefined): DeepResearchUsage { if (!usage) { - return { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 } + return { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cachedTokens: 0, totalTokens: 0 } } const usageLogger = createLogger('DeepResearchUsage') @@ -515,9 +529,10 @@ function extractInteractionUsage(usage: Interactions.Usage | undefined): { usage.total_reasoning_tokens ?? ((usage as Record).total_thought_tokens as number) ?? 0 + const cachedTokens = usage.total_cached_tokens ?? 0 const totalTokens = usage.total_tokens ?? inputTokens + outputTokens - return { inputTokens, outputTokens, reasoningTokens, totalTokens } + return { inputTokens, outputTokens, reasoningTokens, cachedTokens, totalTokens } } /** @@ -526,25 +541,22 @@ function extractInteractionUsage(usage: Interactions.Usage | undefined): { function buildDeepResearchResponse( content: string, model: string, - usage: { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number - }, + usage: DeepResearchUsage, providerStartTime: number, providerStartTimeISO: string, interactionId?: string ): ProviderResponse { const providerEndTime = Date.now() const duration = providerEndTime - providerStartTime + const split = splitGeminiTokens(usage.inputTokens, usage.outputTokens, usage.cachedTokens) return { content, model, tokens: { - input: usage.inputTokens, - output: usage.outputTokens, + input: split.input, + output: split.output, + cacheRead: split.cacheRead, total: usage.totalTokens, }, timing: { @@ -565,7 +577,7 @@ function buildDeepResearchResponse( }, ], }, - cost: calculateCost(model, usage.inputTokens, usage.outputTokens), + cost: priceGeminiTokens(model, split), interactionId, } } @@ -584,20 +596,17 @@ function buildDeepResearchResponse( */ function createDeepResearchStream( stream: AsyncIterable, - onComplete?: ( - content: string, - usage: { - inputTokens: number - outputTokens: number - reasoningTokens: number - totalTokens: number - }, - interactionId?: string - ) => void + onComplete?: (content: string, usage: DeepResearchUsage, interactionId?: string) => void ): ReadableStream { const streamLogger = createLogger('DeepResearchStream') let fullContent = '' - let completionUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 } + let completionUsage: DeepResearchUsage = { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cachedTokens: 0, + totalTokens: 0, + } let completedInteractionId: string | undefined return new ReadableStream({ @@ -769,16 +778,12 @@ export async function executeDeepResearchRequest( const stream = createDeepResearchStream( streamResponse, (content, usage, streamInteractionId) => { + const split = splitGeminiTokens(usage.inputTokens, usage.outputTokens, usage.cachedTokens) + streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.inputTokens, - output: usage.outputTokens, - total: usage.totalTokens, - } + streamingResult.execution.output.tokens = { ...split, total: usage.totalTokens } streamingResult.execution.output.interactionId = streamInteractionId - - const cost = calculateCost(model, usage.inputTokens, usage.outputTokens) - streamingResult.execution.output.cost = cost + streamingResult.execution.output.cost = priceGeminiTokens(model, split) const streamEndTime = Date.now() if (streamingResult.execution.output.providerTiming) { @@ -1031,13 +1036,14 @@ export async function executeGeminiRequest( * no calls and skip the schema. Gemini 3 carries responseJsonSchema * alongside tools, so its live loop keeps structured output. */ - const responseFormatNeedsFinalPass = Boolean(request.responseFormat) && !isGemini3Model(model) - const shouldStreamToolCalls = - (request.streamToolCalls ?? false) && !responseFormatNeedsFinalPass - const shouldStream = request.stream && !tools?.length + const hasActiveTools = Boolean(geminiConfig.tools?.length) + const responseFormatNeedsFinalPass = + Boolean(request.responseFormat) && hasActiveTools && !isGemini3Model(model) + const liveToolLoopSupported = !responseFormatNeedsFinalPass + const shouldStream = request.stream && !hasActiveTools // Live streaming tool loop - if (request.stream && shouldStreamToolCalls && tools?.length) { + if (request.stream && liveToolLoopSupported && hasActiveTools) { logger.info('Using streaming tool loop for Gemini request') const timeSegments: TimeSegment[] = [] @@ -1109,19 +1115,11 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, (content: string, usage: GeminiUsage, thinking?: string) => { - streamingResult.execution.output.content = content - streamingResult.execution.output.tokens = { - input: usage.promptTokenCount, - output: usage.candidatesTokenCount, - total: usage.totalTokenCount, - } + const split = splitGeminiUsage(usage) - const costResult = calculateCost( - model, - usage.promptTokenCount, - usage.candidatesTokenCount - ) - streamingResult.execution.output.cost = costResult + streamingResult.execution.output.content = content + streamingResult.execution.output.tokens = { ...split, total: usage.totalTokenCount } + streamingResult.execution.output.cost = priceGeminiTokens(model, split) if (thinking) { const segment = streamingResult.execution.output.providerTiming?.timeSegments?.[0] @@ -1175,6 +1173,64 @@ export async function executeGeminiRequest( let currentResponse = response let content = '' + const generateFinalSynthesis = async ( + currentState: ExecutionState, + baseConfig: GenerateContentConfig + ): Promise<{ state: ExecutionState; response: GenerateContentResponse }> => { + const finalConfig: GenerateContentConfig = { + ...baseConfig, + tools: undefined, + toolConfig: undefined, + } + if (request.responseFormat && !isGemini3Model(model)) { + finalConfig.responseMimeType = 'application/json' + finalConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema + } + + const finalStartTime = Date.now() + const finalResponse = await ai.models.generateContent({ + model, + contents: currentState.contents, + config: finalConfig, + }) + const finalState = updateStateWithResponse( + currentState, + finalResponse, + model, + finalStartTime, + Date.now() + ) + enrichLastModelSegmentFromGeminiResponse(finalState.timeSegments, finalResponse, { + model, + }) + return { state: finalState, response: finalResponse } + } + const createSettledStreamingResult = ( + currentState: ExecutionState, + settledAnswer: string + ): StreamingExecution => { + const toolCost = sumToolCosts(currentState.toolResults) + const streamingResult = createStreamingResult( + providerStartTime, + providerStartTimeISO, + firstResponseTime, + initialCallTime, + currentState + ) + streamingResult.execution.output.model = model + streamingResult.execution.output.content = settledAnswer + streamingResult.execution.output.cost = { + ...currentState.cost, + toolCost: toolCost || undefined, + total: currentState.cost.total + toolCost, + } + + return { + ...streamingResult, + stream: createSettledAgentEventStream(settledAnswer), + streamFormat: 'agent-events-v1', + } + } // Tool execution loop const functionCalls = response.functionCalls @@ -1182,7 +1238,7 @@ export async function executeGeminiRequest( const functionNames = functionCalls.map((fc) => fc.name).join(', ') logger.info(`Received ${functionCalls.length} function call(s) from Gemini: ${functionNames}`) - while (state.iterationCount < MAX_TOOL_ITERATIONS) { + while (true) { // Extract ALL function call parts from the response (Gemini can return multiple) const functionCallParts = extractAllFunctionCallParts(currentResponse.candidates?.[0]) if (functionCallParts.length === 0) { @@ -1190,6 +1246,27 @@ export async function executeGeminiRequest( break } + if (state.iterationCount >= MAX_TOOL_ITERATIONS) { + logger.info('Gemini tool-batch cap reached; generating a tool-disabled final response') + const finalConfig = buildNextConfig( + geminiConfig, + state, + forcedTools, + request, + logger, + model + ) + const finalSynthesis = await generateFinalSynthesis(state, finalConfig) + state = finalSynthesis.state + currentResponse = finalSynthesis.response + content = extractTextContent(finalSynthesis.response.candidates?.[0]) + + if (request.stream) { + return createSettledStreamingResult(state, content) + } + break + } + const callNames = functionCallParts.map((p) => p.functionCall?.name ?? 'unknown').join(', ') logger.info( `Processing ${functionCallParts.length} function call(s): ${callNames} (iteration ${state.iterationCount + 1})` @@ -1211,121 +1288,7 @@ export async function executeGeminiRequest( state = { ...updatedState, iterationCount: updatedState.iterationCount + 1 } const nextConfig = buildNextConfig(geminiConfig, state, forcedTools, request, logger, model) - // Stream final response if requested - if (request.stream) { - const checkResponse = await ai.models.generateContent({ - model, - contents: state.contents, - config: nextConfig, - }) - state = updateStateWithResponse(state, checkResponse, model, Date.now() - 100, Date.now()) - enrichLastModelSegmentFromGeminiResponse(state.timeSegments, checkResponse, { - model, - }) - - if (checkResponse.functionCalls?.length) { - currentResponse = checkResponse - continue - } - - logger.info('No more function calls, streaming final response') - - if (request.responseFormat) { - nextConfig.tools = undefined - nextConfig.toolConfig = undefined - if (!isGemini3Model(model)) { - nextConfig.responseMimeType = 'application/json' - nextConfig.responseSchema = cleanSchemaForGemini( - request.responseFormat.schema - ) as Schema - } - } else if (nextConfig.tools) { - /** - * The regeneration exists purely to stream the settled answer as - * prose — streamed function calls are never executed. With AUTO the - * model can re-decide to call a tool here, ending the stream with a - * dead functionCall and an empty answer. - */ - nextConfig.toolConfig = { - functionCallingConfig: { mode: FunctionCallingConfigMode.NONE }, - } - } - - /** Settled answer from the check response — kept if the stream ends without text. */ - const checkAnswer = extractTextContent(checkResponse.candidates?.[0]) - - // Capture accumulated cost before streaming - const accumulatedCost = { - input: state.cost.input, - output: state.cost.output, - total: state.cost.total, - } - const accumulatedTokens = { ...state.tokens } - - const streamGenerator = await ai.models.generateContentStream({ - model, - contents: state.contents, - config: nextConfig, - }) - - const streamingResult = createStreamingResult( - providerStartTime, - providerStartTimeISO, - firstResponseTime, - initialCallTime, - state - ) - streamingResult.execution.output.model = model - - const stream = createReadableStreamFromGeminiStream( - streamGenerator, - (streamContent: string, usage: GeminiUsage, thinking?: string) => { - if (!streamContent && checkAnswer) { - logger.warn('Gemini final stream produced no text; keeping tool-loop answer') - } - streamingResult.execution.output.content = streamContent || checkAnswer - streamingResult.execution.output.tokens = { - input: accumulatedTokens.input + usage.promptTokenCount, - output: accumulatedTokens.output + usage.candidatesTokenCount, - total: accumulatedTokens.total + usage.totalTokenCount, - } - - const streamCost = calculateCost( - model, - usage.promptTokenCount, - usage.candidatesTokenCount - ) - const tc = sumToolCosts(state.toolResults) - streamingResult.execution.output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, - pricing: streamCost.pricing, - } - - if (thinking) { - const segments = streamingResult.execution.output.providerTiming?.timeSegments - const lastModel = segments - ? [...segments].reverse().find((s) => s.type === 'model') - : undefined - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - - if (streamingResult.execution.output.providerTiming) { - streamingResult.execution.output.providerTiming.endTime = new Date().toISOString() - streamingResult.execution.output.providerTiming.duration = - Date.now() - providerStartTime - } - } - ) - - return { ...streamingResult, stream, streamFormat: 'agent-events-v1' as const } - } - - // Non-streaming: get next response + /** Resolve the final turn, then project its settled answer when streaming was requested. */ const nextModelStartTime = Date.now() const nextResponse = await ai.models.generateContent({ model, @@ -1337,6 +1300,22 @@ export async function executeGeminiRequest( model, }) currentResponse = nextResponse + + if ( + request.stream && + extractAllFunctionCallParts(nextResponse.candidates?.[0]).length === 0 + ) { + let settledResponse = nextResponse + if (responseFormatNeedsFinalPass) { + logger.info('Generating final schema-configured Gemini response') + const finalSynthesis = await generateFinalSynthesis(state, nextConfig) + state = finalSynthesis.state + settledResponse = finalSynthesis.response + } + + const settledAnswer = extractTextContent(settledResponse.candidates?.[0]) + return createSettledStreamingResult(state, settledAnswer) + } } if (!content) { @@ -1361,7 +1340,7 @@ export async function executeGeminiRequest( modelTime: state.modelTime, toolsTime: state.toolsTime, firstResponseTime, - iterations: state.iterationCount + 1, + iterations: state.timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: state.timeSegments, }, cost: state.cost, @@ -1375,6 +1354,10 @@ export async function executeGeminiRequest( stack: error instanceof Error ? error.stack : undefined, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + const enhancedError = toError(error) Object.assign(enhancedError, { timing: { @@ -1423,7 +1406,7 @@ function enrichLastModelSegmentFromGeminiResponse( })) const usage = convertUsageMetadata(response.usageMetadata) - const cachedContentTokens = response.usageMetadata?.cachedContentTokenCount ?? 0 + const split = splitGeminiUsage(usage) const thoughtsTokens = response.usageMetadata?.thoughtsTokenCount ?? 0 let cost: { input: number; output: number; total: number } | undefined @@ -1433,12 +1416,7 @@ function enrichLastModelSegmentFromGeminiResponse( typeof usage.promptTokenCount === 'number' && typeof usage.candidatesTokenCount === 'number' ) { - const full = calculateCost( - extras.model, - usage.promptTokenCount, - usage.candidatesTokenCount, - cachedContentTokens > 0 - ) + const full = priceGeminiTokens(extras.model, split) cost = { input: full.input, output: full.output, total: full.total } } @@ -1452,7 +1430,7 @@ function enrichLastModelSegmentFromGeminiResponse( input: usage.promptTokenCount, output: usage.candidatesTokenCount, total: usage.totalTokenCount, - ...(cachedContentTokens > 0 && { cacheRead: cachedContentTokens }), + ...(split.cacheRead > 0 && { cacheRead: split.cacheRead }), ...(thoughtsTokens > 0 && { reasoning: thoughtsTokens }), } : undefined, diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts index 5b2e34244ab..e9b4a020f18 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.test.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' import { resetLocalToolIdCounterForTests } from '@/providers/tool-call-id' @@ -19,11 +19,12 @@ async function collectEvents( return events } +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), +})) + vi.mock('@/tools', () => ({ - executeTool: vi.fn(async () => ({ - success: true, - output: { ok: true, url: 'https://httpbin.org/get' }, - })), + executeTool: mockExecuteTool, })) vi.mock('@/providers/utils', () => ({ @@ -39,10 +40,19 @@ vi.mock('@/providers/utils', () => ({ })), sumToolCosts: vi.fn(() => 0), isGemini3Model: vi.fn(() => false), + shouldBillModelUsage: vi.fn(() => true), trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), })) describe('createGeminiStreamingToolLoopStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ + success: true, + output: { ok: true, url: 'https://httpbin.org/get' }, + }) + }) + it('emits thinking, tool lifecycle, then final answer; allocates local tool ids', async () => { resetLocalToolIdCounterForTests() @@ -165,4 +175,209 @@ describe('createGeminiStreamingToolLoopStream', () => { }) ) }) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + mockExecuteTool.mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + const ai = { + models: { + generateContentStream: vi.fn(async () => + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'http_request', + args: { url: 'https://httpbin.org/get' }, + }, + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + } as any + })() + ), + }, + } + const onComplete = vi.fn() + const stream = createGeminiStreamingToolLoopStream({ + ai: ai as any, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 10, output: 5, cacheRead: 0, total: 15 } }) + ) + }) + + it('overrides the turn signal and aborts the active SDK call on consumer cancellation', async () => { + const baseAbortController = new AbortController() + const requestAbortController = new AbortController() + let capturedSignal: AbortSignal | undefined + let resolveCallStarted: (() => void) | undefined + const callStarted = new Promise((resolve) => { + resolveCallStarted = resolve + }) + const generateContentStream = vi.fn( + async ({ config }: { config: { abortSignal?: AbortSignal } }) => { + capturedSignal = config.abortSignal + resolveCallStarted?.() + return await new Promise((_, reject) => { + config.abortSignal?.addEventListener( + 'abort', + () => reject(new DOMException('SDK request aborted', 'AbortError')), + { once: true } + ) + }) + } + ) + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: { abortSignal: baseAbortController.signal }, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + abortSignal: requestAbortController.signal, + } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete: vi.fn(), + }) + const reader = stream.getReader() + const pendingRead = reader.read() + + await callStarted + expect(capturedSignal).toBeDefined() + expect(capturedSignal).not.toBe(baseAbortController.signal) + expect(capturedSignal).not.toBe(requestAbortController.signal) + expect(capturedSignal?.aborted).toBe(false) + + await reader.cancel('consumer cancelled') + + expect(capturedSignal?.aborted).toBe(true) + expect(capturedSignal?.reason).toBe('consumer cancelled') + await expect(pendingRead).resolves.toEqual({ done: true, value: undefined }) + }) + + it('accepts terminal MAX_TOKENS text when no function call is pending', async () => { + const generateContentStream = vi.fn(async () => + (async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: 'Complete answer at the token limit' }] }, + finishReason: 'MAX_TOKENS', + }, + ], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 6, + totalTokenCount: 10, + }, + } as any + })() + ) + const onComplete = vi.fn() + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'answer fully' }] }], + request: { model: 'gemini-2.5-flash' } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events).toContainEqual({ type: 'turn_end', turn: 'final' }) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ + content: 'Complete answer at the token limit', + iterations: 1, + }) + ) + }) + + it.each([ + { + label: 'complete', + parts: [ + { text: 'Partial answer' }, + { functionCall: { name: 'http_request', args: { url: 'https://httpbin.org/get' } } }, + ], + }, + { + label: 'partial', + parts: [{ text: 'Partial answer' }, { functionCall: { args: {} } }], + }, + ])('rejects a $label function call on a MAX_TOKENS turn', async ({ parts }) => { + const generateContentStream = vi.fn(async () => + (async function* () { + yield { + candidates: [{ content: { parts }, finishReason: 'MAX_TOKENS' }], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 6, + totalTokenCount: 10, + }, + } as any + })() + ) + const stream = createGeminiStreamingToolLoopStream({ + ai: { models: { generateContentStream } } as never, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'answer fully' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as never, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + timeSegments: [], + onComplete: vi.fn(), + }) + + await expect(collectEvents(stream)).rejects.toThrow( + 'Gemini stream ended with finish reason MAX_TOKENS' + ) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index 2af1501ef33..f3270a5471e 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -24,6 +24,7 @@ import { } from '@google/genai' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { IterationToolCall } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { @@ -37,18 +38,19 @@ import { isAbortError, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' -import type { ProviderRequest, TimeSegment } from '@/providers/types' -import { - calculateCost, - isGemini3Model, - prepareToolExecution, - sumToolCosts, -} from '@/providers/utils' +import type { ModelPricing, ProviderRequest, TimeSegment } from '@/providers/types' +import { isGemini3Model, prepareToolExecution, sumToolCosts } from '@/providers/utils' import { executeTool } from '@/tools' import type { GeminiUsage } from './types' +import { priceGeminiTokens, splitGeminiUsage } from './usage' + +type GeminiStreamingToolLoopComplete = Omit & { + tokens: { input: number; output: number; cacheRead: number; total: number } +} export interface CreateGeminiStreamingToolLoopStreamOptions { ai: GoogleGenAI @@ -60,7 +62,7 @@ export interface CreateGeminiStreamingToolLoopStreamOptions { timeSegments: TimeSegment[] forcedTools?: string[] toolConfig?: ToolConfig - onComplete: (result: StreamingToolLoopComplete) => void + onComplete: (result: GeminiStreamingToolLoopComplete) => void } /** @@ -109,70 +111,99 @@ function buildNextConfig( async function drainGeminiTurn( stream: AsyncGenerator, controller: ReadableStreamDefaultController, - openTools: Map + openTools: Map, + onIteratorChange: ( + iterator: AsyncIterator | undefined + ) => void = () => {} ): Promise<{ text: string thinking: string functionCalls: StreamedFunctionCall[] + hasFunctionCallPart: boolean usage: GeminiUsage finishReason?: string }> { let text = '' let thinking = '' const functionCalls: StreamedFunctionCall[] = [] + let hasFunctionCallPart = false const seenKeys = new Set() - let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } + let usage: GeminiUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + cachedContentTokenCount: 0, + totalTokenCount: 0, + } let finishReason: string | undefined - for await (const chunk of stream) { - if (chunk.usageMetadata) { - usage = convertUsageMetadata(chunk.usageMetadata) - } - - const candidate = chunk.candidates?.[0] - if (candidate?.finishReason) { - finishReason = String(candidate.finishReason) - } + const iterator = stream[Symbol.asyncIterator]() + onIteratorChange(iterator) + try { + while (true) { + const next = await iterator.next() + if (next.done) break + const chunk = next.value + if (chunk.promptFeedback?.blockReason) { + throw new Error( + `Gemini prompt blocked: ${chunk.promptFeedback.blockReason}${ + chunk.promptFeedback.blockReasonMessage + ? ` (${chunk.promptFeedback.blockReasonMessage})` + : '' + }` + ) + } + if (chunk.usageMetadata) { + usage = convertUsageMetadata(chunk.usageMetadata) + } - const parts = candidate?.content?.parts - if (!Array.isArray(parts)) { - const fallback = chunk.text - if (fallback) { - text += fallback - controller.enqueue({ type: 'text_delta', text: fallback, turn: 'pending' }) + const candidate = chunk.candidates?.[0] + if (candidate?.finishReason) { + finishReason = String(candidate.finishReason) } - continue - } - for (const part of parts) { - if (part.functionCall) { - const localId = ensureToolCallId(part.functionCall.id, 'gemini') - const name = part.functionCall.name ?? '' - if (!seenKeys.has(localId) && name) { - seenKeys.add(localId) - functionCalls.push({ part, localId }) - if (!openTools.has(localId)) { - openTools.set(localId, name) - controller.enqueue({ type: 'tool_call_start', id: localId, name }) - } + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + const fallback = chunk.text + if (fallback) { + text += fallback + controller.enqueue({ type: 'text_delta', text: fallback, turn: 'pending' }) } continue } - if (!part.text) continue - if (part.thought === true) { - thinking += part.text - controller.enqueue({ type: 'thinking_delta', text: part.text }) - } else { - text += part.text - // Live pending text: sinks render it now; the pump projects it to the - // answer only when this turn's turn_end says 'final'. - controller.enqueue({ type: 'text_delta', text: part.text, turn: 'pending' }) + for (const part of parts) { + if (part.functionCall) { + hasFunctionCallPart = true + const localId = ensureToolCallId(part.functionCall.id, 'gemini') + const name = part.functionCall.name ?? '' + if (!seenKeys.has(localId) && name) { + seenKeys.add(localId) + functionCalls.push({ part, localId }) + if (!openTools.has(localId)) { + openTools.set(localId, name) + controller.enqueue({ type: 'tool_call_start', id: localId, name }) + } + } + continue + } + + if (!part.text) continue + if (part.thought === true) { + thinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + text += part.text + // Live pending text: sinks render it now; the pump projects it to the + // answer only when this turn's turn_end says 'final'. + controller.enqueue({ type: 'text_delta', text: part.text, turn: 'pending' }) + } } } + } finally { + onIteratorChange(undefined) } - return { text, thinking, functionCalls, usage, finishReason } + return { text, thinking, functionCalls, hasFunctionCallPart, usage, finishReason } } /** @@ -192,345 +223,429 @@ export function createGeminiStreamingToolLoopStream( onComplete, } = options const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeStreamIterator: AsyncIterator | undefined + let consumerCancelled = false - return new ReadableStream({ - async start(controller) { - let contents = [...initialContents] - let currentToolConfig = options.toolConfig - let usedForcedTools: string[] = [] - - let content = '' - let iterationCount = 0 - let modelCalls = 0 - let sawFinalTurn = false - let modelTime = 0 - let toolsTime = 0 - let firstResponseTime = 0 - const tokens = { input: 0, output: 0, total: 0 } - let costInput = 0 - let costOutput = 0 - let costTotal = 0 - let latestPricing: ReturnType['pricing'] | undefined - const toolCalls: unknown[] = [] - const toolResults: Record[] = [] - const openToolStarts = new Map() - - try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { - settleOpenTools(controller, openToolStarts, 'cancelled') - throw new DOMException('Stream aborted', 'AbortError') - } + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } - const turnConfig = buildNextConfig( - baseConfig, - currentToolConfig, - usedForcedTools, - forcedTools, - request, - logger, - model - ) - - const modelStart = Date.now() - const streamGenerator = await ai.models.generateContentStream({ - model, - contents, - config: turnConfig, + return new ReadableStream({ + start(controller) { + void (async () => { + let contents = [...initialContents] + let currentToolConfig = options.toolConfig + let usedForcedTools: string[] = [] + + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let sawFinalTurn = false + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, cacheRead: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ModelPricing | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, }) + } - const drained = await drainGeminiTurn(streamGenerator, controller, openToolStarts) - const modelEnd = Date.now() - const thisModelTime = modelEnd - modelStart - modelTime += thisModelTime - modelCalls++ - if (iterationCount === 0) { - firstResponseTime = thisModelTime - } + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + if (!consumerCancelled) { + settleOpenTools(controller, openToolStarts, 'cancelled') + } + throw new DOMException('Stream aborted', 'AbortError') + } - timeSegments.push({ - type: 'model', - name: model, - startTime: modelStart, - endTime: modelEnd, - duration: thisModelTime, - }) + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + const turnConfig = finalSynthesis + ? { ...baseConfig, tools: undefined, toolConfig: undefined } + : buildNextConfig( + baseConfig, + currentToolConfig, + usedForcedTools, + forcedTools, + request, + logger, + model + ) + + const modelStart = Date.now() + const streamGenerator = await ai.models.generateContentStream({ + model, + contents, + config: { + ...turnConfig, + abortSignal: loopAbortController.signal, + }, + }) + + const drained = await drainGeminiTurn( + streamGenerator, + controller, + openToolStarts, + (iterator) => { + activeStreamIterator = iterator + } + ) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + modelCalls++ + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } - tokens.input += drained.usage.promptTokenCount - tokens.output += drained.usage.candidatesTokenCount - tokens.total += drained.usage.totalTokenCount - - const turnCost = calculateCost( - model, - drained.usage.promptTokenCount, - drained.usage.candidatesTokenCount - ) - costInput += turnCost.input - costOutput += turnCost.output - costTotal += turnCost.total - latestPricing = turnCost.pricing - - const turnTag = drained.functionCalls.length > 0 ? 'intermediate' : 'final' - controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (drained.text) { + timeSegments.push({ + type: 'model', + name: model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + const split = splitGeminiUsage(drained.usage) + tokens.input += split.input + tokens.output += split.output + tokens.cacheRead += split.cacheRead + tokens.total += drained.usage.totalTokenCount + + const turnCost = priceGeminiTokens(model, split) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + const isCompleteTurn = + drained.finishReason === 'STOP' || + (drained.finishReason === 'MAX_TOKENS' && + Boolean(drained.text) && + !drained.hasFunctionCallPart) + if (!isCompleteTurn) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `Gemini stream ended with finish reason ${drained.finishReason ?? 'missing'}` + ) + } + if (finalSynthesis && drained.functionCalls.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error('Gemini returned function calls during final synthesis') + } + + const turnTag = drained.functionCalls.length > 0 ? 'intermediate' : 'final' + controller.enqueue({ type: 'turn_end', turn: turnTag }) content = drained.text - } - const toolCallsForEnrich: IterationToolCall[] = drained.functionCalls - .filter((fc) => Boolean(fc.part.functionCall)) - .map((fc) => ({ - id: fc.localId, - name: fc.part.functionCall?.name ?? '', - arguments: (fc.part.functionCall?.args ?? {}) as Record, - })) + const toolCallsForEnrich: IterationToolCall[] = drained.functionCalls + .filter((fc) => Boolean(fc.part.functionCall)) + .map((fc) => ({ + id: fc.localId, + name: fc.part.functionCall?.name ?? '', + arguments: (fc.part.functionCall?.args ?? {}) as Record, + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + thinkingContent: drained.thinking || undefined, + toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined, + finishReason: drained.finishReason, + tokens: { + input: drained.usage.promptTokenCount, + output: drained.usage.candidatesTokenCount, + total: drained.usage.totalTokenCount, + ...(split.cacheRead > 0 && { cacheRead: split.cacheRead }), + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'google', + }) - enrichLastModelSegment(timeSegments, { - assistantContent: drained.text || undefined, - thinkingContent: drained.thinking || undefined, - toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined, - finishReason: drained.finishReason, - tokens: { - input: drained.usage.promptTokenCount, - output: drained.usage.candidatesTokenCount, - total: drained.usage.totalTokenCount, - }, - cost: { - input: turnCost.input, - output: turnCost.output, - total: turnCost.total, - }, - provider: 'google', - }) + const forcedCheck = checkForForcedToolUsage( + drained.functionCalls + .map((fc) => fc.part.functionCall) + .filter((fc): fc is NonNullable => Boolean(fc)), + currentToolConfig, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + usedForcedTools = forcedCheck.usedForcedTools + currentToolConfig = forcedCheck.nextToolConfig + } - const forcedCheck = checkForForcedToolUsage( - drained.functionCalls - .map((fc) => fc.part.functionCall) - .filter((fc): fc is NonNullable => Boolean(fc)), - currentToolConfig, - forcedTools, - usedForcedTools - ) - if (forcedCheck) { - usedForcedTools = forcedCheck.usedForcedTools - currentToolConfig = forcedCheck.nextToolConfig - } + if (drained.functionCalls.length === 0) { + sawFinalTurn = true + break + } - if (drained.functionCalls.length === 0) { - sawFinalTurn = true - break - } + const toolsStartTime = Date.now() - const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + drained.functionCalls.map(async ({ part, localId }) => { + const functionCall = part.functionCall! + const toolCallId = localId + const toolName = functionCall.name ?? '' + const toolArgs = isRecordLike(functionCall.args) ? functionCall.args : undefined + const toolCallStartTime = Date.now() - const orderedResults = await Promise.all( - drained.functionCalls.map(async ({ part, localId }) => { - const functionCall = part.functionCall! - const toolCallId = localId - const toolName = functionCall.name ?? '' - const toolArgs = (functionCall.args ?? {}) as Record - const toolCallStartTime = Date.now() + try { + if (loopAbortController.signal.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + if (!toolArgs) { + throw new Error(`Arguments for tool "${toolName}" must be an object`) + } - try { - if (request.abortSignal?.aborted) { - throw new DOMException('Stream aborted', 'AbortError') - } + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + part, + toolCallId, + toolName, + toolArgs, + toolParams: {} as Record, + resultContent: { + error: true, + message: `Tool ${toolName} not found`, + tool: toolName, + }, + result: undefined as + | { success: boolean; output?: unknown; error?: string } + | undefined, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + success: false, + } + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + return value + } - const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) { - const value = { + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: loopAbortController.signal, + }) + const toolCallEndTime = Date.now() + const resultContent: Record = result.success + ? ensureStructResponse(result.output) + : { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { part, toolCallId, toolName, toolArgs, - toolParams: {} as Record, - resultContent: { - error: true, - message: `Tool ${toolName} not found`, - tool: toolName, - }, - result: undefined as - | { success: boolean; output?: unknown; error?: string } - | undefined, + toolParams, + resultContent, + result, startTime: toolCallStartTime, - endTime: Date.now(), - duration: Date.now() - toolCallStartTime, - status: 'error' as ToolCallEndStatus, - success: false, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: result.success, + } + } catch (error) { + const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolCallId) + if (!consumerCancelled) { + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'cancelled', + }) + } + throw error } + if (isAbortError(error)) { + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing function call:', { + error: toError(error).message, + functionName: toolName, + }) + const status: ToolCallEndStatus = 'error' openToolStarts.delete(toolCallId) controller.enqueue({ type: 'tool_call_end', id: toolCallId, name: toolName, - status: 'error', + status, }) - return value - } - - const { toolParams, executionParams } = prepareToolExecution( - tool, - toolArgs, - request - ) - const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, - }) - const toolCallEndTime = Date.now() - const resultContent: Record = result.success - ? ensureStructResponse(result.output) - : { + return { + part, + toolCallId, + toolName, + toolArgs: toolArgs ?? {}, + toolParams: {} as Record, + resultContent: { error: true, - message: result.error || 'Tool execution failed', + message: getErrorMessage(error, 'Tool execution failed'), tool: toolName, - } - const status: ToolCallEndStatus = result.success ? 'success' : 'error' - openToolStarts.delete(toolCallId) - controller.enqueue({ - type: 'tool_call_end', - id: toolCallId, - name: toolName, - status, - }) - return { - part, - toolCallId, - toolName, - toolArgs, - toolParams, - resultContent, - result, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - status, - success: result.success, - } - } catch (error) { - const toolCallEndTime = Date.now() - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing function call:', { - error: toError(error).message, - functionName: toolName, - }) - } - const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' - openToolStarts.delete(toolCallId) - controller.enqueue({ - type: 'tool_call_end', - id: toolCallId, - name: toolName, - status, - }) - return { - part, - toolCallId, - toolName, - toolArgs, - toolParams: {} as Record, - resultContent: { - error: true, - message: getErrorMessage(error, 'Tool execution failed'), - tool: toolName, - }, - result: undefined, - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - status, - success: false, + }, + result: undefined, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: false, + } } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + /** + * Echo the model's functionCall parts verbatim (signatures and any + * model-provided ids must round-trip untouched). A functionResponse + * id is attached only when the model itself provided one. + */ + const modelParts: Part[] = orderedResults.map((r) => r.part) + const userParts: Part[] = orderedResults.map((r) => ({ + functionResponse: { + name: r.toolName, + response: r.resultContent, + ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), + }, + })) + + contents = [ + ...contents, + { role: 'model', parts: modelParts }, + { role: 'user', parts: userParts }, + ] + + for (const r of orderedResults) { + toolCalls.push({ + name: r.toolName, + arguments: r.toolParams, + startTime: new Date(r.startTime).toISOString(), + endTime: new Date(r.endTime).toISOString(), + duration: r.duration, + result: r.resultContent, + success: r.success, + }) + if ( + r.success && + r.result?.output && + typeof r.result.output === 'object' && + !Array.isArray(r.result.output) + ) { + toolResults.push(r.result.output as Record) } - }) - ) - - toolsTime += Date.now() - toolsStartTime - - /** - * Echo the model's functionCall parts verbatim (signatures and any - * model-provided ids must round-trip untouched). A functionResponse - * id is attached only when the model itself provided one. - */ - const modelParts: Part[] = orderedResults.map((r) => r.part) - const userParts: Part[] = orderedResults.map((r) => ({ - functionResponse: { - name: r.toolName, - response: r.resultContent, - ...(r.part.functionCall?.id ? { id: r.part.functionCall.id } : {}), - }, - })) - - contents = [ - ...contents, - { role: 'model', parts: modelParts }, - { role: 'user', parts: userParts }, - ] - - for (const r of orderedResults) { - toolCalls.push({ - name: r.toolName, - arguments: r.toolParams, - startTime: new Date(r.startTime).toISOString(), - endTime: new Date(r.endTime).toISOString(), - duration: r.duration, - result: r.resultContent, - success: r.success, - }) - if (r.success && r.result?.output) { - toolResults.push(r.result.output as Record) + timeSegments.push({ + type: 'tool', + name: r.toolName, + startTime: r.startTime, + endTime: r.endTime, + duration: r.duration, + toolCallId: r.toolCallId, + }) } - timeSegments.push({ - type: 'tool', - name: r.toolName, - startTime: r.startTime, - endTime: r.endTime, - duration: r.duration, - toolCallId: r.toolCallId, - }) - } - iterationCount += 1 - } + iterationCount += 1 + } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) - } + if (!sawFinalTurn) { + throw new Error('Gemini tool loop ended without a final response') + } - const toolCost = sumToolCosts(toolResults) - onComplete({ - content, - tokens, - cost: { - input: costInput, - output: costOutput, - toolCost: toolCost || undefined, - total: costTotal + toolCost, - pricing: latestPricing, - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) - controller.close() - } catch (error) { - if (isAbortError(error) || request.abortSignal?.aborted) { - settleOpenTools(controller, openToolStarts, 'cancelled') - } else { - settleOpenTools(controller, openToolStarts, 'error') - logger.error('Gemini streaming tool loop failed', { - error: toError(error).message, + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error('Gemini streaming tool loop failed', { + error: toError(cause).message, + }), }) + } finally { + activeStreamIterator = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) } - controller.error(error) - } + })().catch((error) => { + // `start` cannot be async (the loop must not block the first pull), so + // a throw escaping the IIFE would surface as an unhandled rejection. + logger.error('Unhandled failure in Gemini streaming tool loop', { + error: toError(error).message, + }) + }) + }, + async cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + await activeStreamIterator?.return?.() + request.abortSignal?.removeEventListener('abort', abortFromRequest) }, }) } diff --git a/apps/sim/providers/gemini/types.ts b/apps/sim/providers/gemini/types.ts index e74b69fe44a..b605e835a78 100644 --- a/apps/sim/providers/gemini/types.ts +++ b/apps/sim/providers/gemini/types.ts @@ -2,11 +2,17 @@ import type { Content, ToolConfig } from '@google/genai' import type { FunctionCallResponse, ModelPricing, TimeSegment } from '@/providers/types' /** - * Usage metadata from Gemini responses + * Usage metadata from Gemini responses. + * + * `cachedContentTokenCount` is a SUBSET of `promptTokenCount`, not a sibling of + * it — Gemini counts implicitly cached prompt tokens inside the prompt total and + * only discounts their rate. Anything pricing this usage must subtract it out + * before charging the base input rate. */ export interface GeminiUsage { promptTokenCount: number candidatesTokenCount: number + cachedContentTokenCount: number totalTokenCount: number } @@ -23,7 +29,8 @@ interface ParsedFunctionCall { */ export interface ExecutionState { contents: Content[] - tokens: { input: number; output: number; total: number } + /** `input` excludes `cacheRead`; `total` counts both, plus output. */ + tokens: { input: number; output: number; cacheRead: number; total: number } cost: { input: number; output: number; total: number; pricing: ModelPricing } toolCalls: FunctionCallResponse[] toolResults: Record[] diff --git a/apps/sim/providers/gemini/usage.test.ts b/apps/sim/providers/gemini/usage.test.ts new file mode 100644 index 00000000000..bcaf8644035 --- /dev/null +++ b/apps/sim/providers/gemini/usage.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' +import { calculateCost } from '@/providers/utils' + +/** input 0.30/M, cachedInput 0.03/M, output 2.50/M. */ +const MODEL = 'gemini-2.5-flash' + +describe('splitGeminiTokens', () => { + it('subtracts the cached subset out of the prompt total', () => { + expect(splitGeminiTokens(100_000, 1_000, 80_000)).toEqual({ + input: 20_000, + output: 1_000, + cacheRead: 80_000, + }) + }) + + it('leaves the prompt total intact when nothing was cached', () => { + expect(splitGeminiTokens(100_000, 1_000, 0)).toEqual({ + input: 100_000, + output: 1_000, + cacheRead: 0, + }) + }) + + it('never bills more tokens than the prompt contained when cached is over-reported', () => { + // Cached is a subset of the prompt total, so it can never exceed it. + // Leaving it unclamped would bill 4,000 cached tokens on a 1,000-token prompt. + expect(splitGeminiTokens(1_000, 10, 4_000)).toEqual({ + input: 0, + output: 10, + cacheRead: 1_000, + }) + expect(splitGeminiTokens(1_000, 10, -5)).toEqual({ + input: 1_000, + output: 10, + cacheRead: 0, + }) + }) +}) + +describe('splitGeminiUsage', () => { + it('reads the cached subset off the generateContent usage shape', () => { + expect( + splitGeminiUsage({ + promptTokenCount: 50_000, + candidatesTokenCount: 500, + cachedContentTokenCount: 30_000, + totalTokenCount: 50_500, + }) + ).toEqual({ input: 20_000, output: 500, cacheRead: 30_000 }) + }) +}) + +describe('priceGeminiTokens', () => { + it('charges cached tokens at the discounted rate instead of the base input rate', () => { + const cost = priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 80_000)) + + expect(cost.input).toBeCloseTo(20_000 * 0.0000003 + 80_000 * 0.00000003, 10) + expect(cost.output).toBeCloseTo(1_000 * 0.0000025, 10) + expect(cost.total).toBeCloseTo(0.0109, 10) + }) + + it('costs strictly less than pricing the whole prompt total at the base rate', () => { + const cacheBlind = calculateCost(MODEL, 100_000, 1_000) + const cacheAware = priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 80_000)) + + expect(cacheBlind.total).toBeCloseTo(0.0325, 10) + expect(cacheAware.total).toBeLessThan(cacheBlind.total) + }) + + it('matches plain calculateCost exactly when there is no cache hit', () => { + expect(priceGeminiTokens(MODEL, splitGeminiTokens(100_000, 1_000, 0))).toEqual( + calculateCost(MODEL, 100_000, 1_000) + ) + }) +}) diff --git a/apps/sim/providers/gemini/usage.ts b/apps/sim/providers/gemini/usage.ts new file mode 100644 index 00000000000..f79f11d2ec9 --- /dev/null +++ b/apps/sim/providers/gemini/usage.ts @@ -0,0 +1,62 @@ +import { LIST_PRICE_POLICY, type PricedModelCost, priceModelUsage } from '@/providers/cost-policy' +import type { GeminiUsage } from '@/providers/gemini/types' + +/** + * One Gemini response's tokens split into the buckets that price differently. + * `input` is the uncached remainder, so `input + cacheRead` is the prompt total + * Gemini reported. + */ +export interface GeminiTokenSplit { + input: number + output: number + cacheRead: number +} + +/** + * Splits a cache-inclusive Gemini prompt total into the uncached remainder and + * the cache read. + * + * Gemini's implicit context cache (on by default from 2.5 onward) reports cached + * tokens as a subset of the prompt total — `cachedContentTokenCount` inside + * `promptTokenCount` on generateContent, `total_cached_tokens` inside + * `total_input_tokens` on the Interactions API. Charging the prompt total at the + * base input rate therefore bills cache hits at full price; the subtraction is + * what routes them to the model's discounted `cachedInput` rate instead. + */ +export function splitGeminiTokens( + promptTokens: number, + candidatesTokens: number, + cachedTokens: number +): GeminiTokenSplit { + const prompt = Math.max(0, promptTokens) + // Clamped to the prompt total: a payload reporting more cached tokens than it + // processed would otherwise bill more input than the request contained. + const cacheRead = Math.min(Math.max(0, cachedTokens), prompt) + + return { + input: prompt - cacheRead, + output: candidatesTokens, + cacheRead, + } +} + +/** {@link splitGeminiTokens} for the generateContent usage shape. */ +export function splitGeminiUsage(usage: GeminiUsage): GeminiTokenSplit { + return splitGeminiTokens( + usage.promptTokenCount, + usage.candidatesTokenCount, + usage.cachedContentTokenCount + ) +} + +/** + * Prices a split through the shared cache-aware pricing function. With no cache + * hit this matches pricing the whole prompt total at the base input rate. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function priceGeminiTokens(model: string, split: GeminiTokenSplit): PricedModelCost { + return priceModelUsage(model, split, LIST_PRICE_POLICY) +} diff --git a/apps/sim/providers/google/utils.stream.test.ts b/apps/sim/providers/google/utils.stream.test.ts index 2e3a79e7ecb..1b2dfaa1c79 100644 --- a/apps/sim/providers/google/utils.stream.test.ts +++ b/apps/sim/providers/google/utils.stream.test.ts @@ -74,4 +74,21 @@ describe('createReadableStreamFromGeminiStream', () => { .join('') ).toContain('Just text') }) + + it('surfaces blocked prompts instead of completing an empty stream', async () => { + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + promptFeedback: { + blockReason: 'SAFETY', + blockReasonMessage: 'Prompt violated safety policy', + }, + } as any + })() + ) + + await expect(collectEvents(stream)).rejects.toThrow( + 'Gemini prompt blocked: SAFETY (Prompt violated safety policy)' + ) + }) }) diff --git a/apps/sim/providers/google/utils.test.ts b/apps/sim/providers/google/utils.test.ts index 7cd7fff8277..ead73247543 100644 --- a/apps/sim/providers/google/utils.test.ts +++ b/apps/sim/providers/google/utils.test.ts @@ -4,12 +4,57 @@ import { describe, expect, it } from 'vitest' import { convertToGeminiFormat, + convertUsageMetadata, ensureStructResponse, mapToThinkingBudget, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' import type { ProviderRequest } from '@/providers/types' +describe('convertUsageMetadata', () => { + it('carries the cached prompt subset through so callers can discount it', () => { + expect( + convertUsageMetadata({ + promptTokenCount: 100_000, + cachedContentTokenCount: 80_000, + candidatesTokenCount: 1_000, + totalTokenCount: 101_000, + }) + ).toEqual({ + promptTokenCount: 100_000, + candidatesTokenCount: 1_000, + cachedContentTokenCount: 80_000, + totalTokenCount: 101_000, + }) + }) + + it('reports no cache hit when the field is absent or the metadata is missing', () => { + expect( + convertUsageMetadata({ + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }).cachedContentTokenCount + ).toBe(0) + expect(convertUsageMetadata(undefined).cachedContentTokenCount).toBe(0) + }) + + it('keeps the cached count a subset of the tool-use-inclusive prompt total', () => { + const usage = convertUsageMetadata({ + promptTokenCount: 8_000, + toolUsePromptTokenCount: 2_000, + cachedContentTokenCount: 6_000, + candidatesTokenCount: 100, + thoughtsTokenCount: 40, + totalTokenCount: 10_140, + }) + + expect(usage.promptTokenCount).toBe(10_000) + expect(usage.candidatesTokenCount).toBe(140) + expect(usage.cachedContentTokenCount).toBeLessThan(usage.promptTokenCount) + }) +}) + 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) diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 3a389261011..63822da75d0 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { buildGeminiMessageParts } from '@/providers/attachments' +import type { GeminiUsage } from '@/providers/gemini/types' import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest } from '@/providers/types' import { trackForcedToolUsage } from '@/providers/utils' @@ -38,23 +39,6 @@ export function ensureStructResponse(value: unknown): Record { 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 -} - /** * Removes additionalProperties from a schema object (not supported by Gemini) */ @@ -93,40 +77,6 @@ export function extractTextContent(candidate: Candidate | undefined): string { 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, - } - } - } - - 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, @@ -142,6 +92,10 @@ export function extractAllFunctionCallParts(candidate: Candidate | undefined): P * 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. + * + * `cachedContentTokenCount` is carried through unchanged — it stays a subset of + * the reported `promptTokenCount`, and callers subtract it when they need the + * tokens billed at the base input rate. */ export function convertUsageMetadata( usageMetadata: GenerateContentResponseUsageMetadata | undefined @@ -153,6 +107,7 @@ export function convertUsageMetadata( return { promptTokenCount, candidatesTokenCount, + cachedContentTokenCount: usageMetadata?.cachedContentTokenCount ?? 0, totalTokenCount: usageMetadata?.totalTokenCount ?? 0, } } @@ -291,12 +246,32 @@ export function createReadableStreamFromGeminiStream( ): ReadableStream { let fullContent = '' let fullThinking = '' - let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } + let usage: GeminiUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + cachedContentTokenCount: 0, + totalTokenCount: 0, + } + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { try { - for await (const chunk of stream) { + streamIterator = stream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const chunk = next.value + if (chunk.promptFeedback?.blockReason) { + throw new Error( + `Gemini prompt blocked: ${chunk.promptFeedback.blockReason}${ + chunk.promptFeedback.blockReasonMessage + ? ` (${chunk.promptFeedback.blockReasonMessage})` + : '' + }` + ) + } if (chunk.usageMetadata) { usage = convertUsageMetadata(chunk.usageMetadata) } @@ -324,15 +299,22 @@ export function createReadableStreamFromGeminiStream( } } + if (cancelled) return onComplete?.(fullContent, usage, fullThinking || undefined) controller.close() } catch (error) { - logger.error('Error reading Google Gemini stream', { - error: toError(error).message, - }) - controller.error(error) + if (!cancelled) { + logger.error('Error reading Google Gemini stream', { + error: toError(error).message, + }) + controller.error(error) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts index 0b5ca8e4c84..7cf12b31de0 100644 --- a/apps/sim/providers/groq/index.test.ts +++ b/apps/sim/providers/groq/index.test.ts @@ -2,10 +2,13 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import type { ProviderRequest } from '@/providers/types' -const { mockCreate } = vi.hoisted(() => ({ +const { mockCreate, mockExecuteTool, mockPrepareToolsWithUsageControl } = vi.hoisted(() => ({ mockCreate: vi.fn(), + mockExecuteTool: vi.fn(), + mockPrepareToolsWithUsageControl: vi.fn(), })) vi.mock('groq-sdk', () => ({ @@ -46,17 +49,12 @@ vi.mock('@/providers/trace-enrichment', () => ({ 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: [], - hasFilteredTools: false, - })), + prepareToolsWithUsageControl: mockPrepareToolsWithUsageControl, sumToolCosts: vi.fn(() => 0), trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) -vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { groqProvider } from '@/providers/groq/index' @@ -72,6 +70,14 @@ function request(overrides: Partial = {}): ProviderRequest { describe('groqProvider reasoning payload', () => { beforeEach(() => { mockCreate.mockReset() + mockExecuteTool.mockReset() + mockPrepareToolsWithUsageControl.mockReset() + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }) mockCreate.mockResolvedValue({ choices: [{ message: { content: 'ok', tool_calls: [] } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, @@ -128,4 +134,47 @@ describe('groqProvider reasoning payload', () => { expect(payload.reasoning_format).toBeUndefined() expect(payload.reasoning_effort).toBe('none') }) + + it('selects the live tool loop without a caller flag', async () => { + mockPrepareToolsWithUsageControl.mockReturnValue({ + tools: [ + { + type: 'function', + function: { name: 'lookup', description: 'Lookup', parameters: {} }, + }, + ], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + }) + vi.mocked(createOpenAICompatStreamingToolLoopStream).mockReturnValue( + new ReadableStream() as never + ) + + const result = (await groqProvider.executeRequest( + request({ + stream: true, + tools: [ + { + id: 'lookup', + name: 'lookup', + description: 'Lookup', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + }) + )) as unknown as { + createStream: (handles: { + output: { content?: string } + finalizeTiming: () => void + }) => ReadableStream + } + + const output: { content?: string } = {} + result.createStream({ output, finalizeTiming: vi.fn() }) + + expect(createOpenAICompatStreamingToolLoopStream).toHaveBeenCalledTimes(1) + expect(mockCreate).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 78d3d3b0e14..8185c4b3de6 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { Groq } from 'groq-sdk' import type { ChatCompletionCreateParamsStreaming as GroqChatCompletionCreateParamsStreaming } from 'groq-sdk/resources/chat/completions' import type { @@ -13,6 +14,7 @@ import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -26,7 +28,6 @@ import { calculateCost, prepareToolExecution, prepareToolsWithUsageControl, - sumToolCosts, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -139,9 +140,7 @@ export const groqProvider: ProviderConfig = { } } - const shouldStreamToolCalls = request.streamToolCalls ?? false - - if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + if (request.stream && payload.tools?.length) { logger.info('Using streaming tool loop for Groq request') const providerStartTime = Date.now() @@ -184,9 +183,7 @@ export const groqProvider: ProviderConfig = { logger, timeSegments, forcedTools, - preserveAssistantReasoning: - (!!request.thinkingLevel && request.thinkingLevel !== 'none') || - (payload.model as string).includes('gpt-oss'), + preserveAssistantReasoning: true, onComplete: (result) => { output.content = result.content output.tokens = result.tokens @@ -204,7 +201,7 @@ export const groqProvider: ProviderConfig = { }) } - if (request.stream && (!tools || tools.length === 0)) { + if (request.stream && !payload.tools?.length) { logger.info('Using streaming response for Groq request (no tools)') const providerStartTime = Date.now() @@ -227,7 +224,7 @@ export const groqProvider: ProviderConfig = { initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => + createStream: ({ output, finalizeTiming }) => createReadableStreamFromGroqStream( // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes streamResponse as unknown as AsyncIterable, @@ -256,6 +253,7 @@ export const groqProvider: ProviderConfig = { segment.thinkingContent = thinking } } + finalizeTiming() } ), }) @@ -324,10 +322,25 @@ export const groqProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -345,6 +358,9 @@ export const groqProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -364,11 +380,14 @@ export const groqProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + const assistantReasoning = (assistantMessage as { reasoning?: string } | undefined) + ?.reasoning currentMessages.push({ role: 'assistant', - content: null, + content: assistantMessage?.content ?? '', tool_calls: toolCallsInResponse.map((tc) => ({ id: tc.id, type: 'function', @@ -377,13 +396,12 @@ export const groqProvider: ProviderConfig = { arguments: tc.function.arguments, }, })), + ...(assistantReasoning ? { reasoning: assistantReasoning } : {}), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -394,10 +412,12 @@ export const groqProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -494,101 +514,7 @@ export const groqProvider: ProviderConfig = { } } catch (error) { logger.error('Error in Groq request:', { error }) - } - - if (request.stream) { - logger.info('Using streaming for final Groq response after tool processing') - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path (re-applying the - * original forced tool_choice here would even guarantee a dead call). - */ - const streamingPayload = { - ...payload, - messages: currentMessages, - tool_choice: 'none' as const, - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromGroqStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; groq-sdk stream chunks are wire-compatible with the OpenAI ChatCompletionChunk shape the adapter consumes - streamResponse as unknown as AsyncIterable, - (streamedContent, usage, thinking) => { - if (!streamedContent && content) { - logger.warn('Groq final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || 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, - } - - if (thinking) { - const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - } - ), - }) - - return streamingResult + throw error } const providerEndTime = Date.now() @@ -622,6 +548,9 @@ export const groqProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 2f52c842461..7b0ee9697e8 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { envFlagsMockFns, resetEnvFlagsMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetApiKeyWithBYOK, mockExecuteRequest } = vi.hoisted(() => ({ mockGetApiKeyWithBYOK: vi.fn(), @@ -114,6 +115,106 @@ describe('executeProviderRequest — BYOK regression', () => { expect(segment?.cost?.total).toBeCloseTo(HOSTED_RATE_TOTAL_COST, 6) }) + /** + * Provider cost is now preferred over recomputation, because only the + * provider knows its cache tiers. Tool cost is the hazard in that branch: + * `executeProviderRequest` re-derives it from `toolResults`, so a provider + * that folded it into its own total must not have it counted twice. + */ + it('counts a provider-folded tool cost exactly once', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150 }, + cost: { + input: 0.0005, + output: 0.00125, + total: 0.00675, + toolCost: 0.005, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + }, + toolResults: [{ cost: { total: 0.005 } }], + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.toolCost).toBeCloseTo(0.005, 8) + expect(result.cost?.total).toBeCloseTo(0.00675, 8) + }) + + /** + * Gemini hands the same cost object to its response and its model segment. + * Adding tool cost by mutation would charge it to the segment too. + */ + it('does not leak tool cost into a segment sharing the provider cost object', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + envFlagsMockFns.getCostMultiplier.mockReturnValue(1) + const sharedCost = { + input: 0.0005, + output: 0.00125, + total: 0.00175, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + } + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150 }, + cost: sharedCost, + toolResults: [{ cost: { total: 0.004 } }], + 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: sharedCost, + }, + ], + }, + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.total).toBeCloseTo(0.00575, 8) + expect(result.timing?.timeSegments?.[0]?.cost?.total).toBeCloseTo(0.00175, 8) + }) + + it('keeps the provider cost rather than recomputing it from tokens', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + // Cache-tier pricing this layer cannot rebuild from `tokens` alone. + mockExecuteRequest.mockResolvedValue({ + content: 'hi', + model: 'claude-opus-4-6', + tokens: { input: 100, output: 50, total: 150, cacheRead: 900, cacheWrite: 400 }, + cost: { + input: 0.0123, + output: 0.00125, + total: 0.01355, + pricing: { input: 5.0, output: 25.0, updatedAt: '2026-04-01' }, + }, + } as ProviderResponse) + + const result = (await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + })) as ProviderResponse + + expect(result.cost?.input).toBeCloseTo(0.0123, 8) + expect(result.cost?.total).toBeCloseTo(0.01355, 8) + }) + it('preserves tool segment cost (BYOK does not suppress tool charges)', async () => { mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true }) const responseWithToolSegment: ProviderResponse = { @@ -215,3 +316,99 @@ describe('executeProviderRequest — BYOK regression', () => { expect(segments[0].cost.output).toBe(0) }) }) + +/** + * Streaming and non-streaming must charge identically. Providers price tokens + * inside the stream drain without knowing key provenance or the margin, so the + * shared policy is installed on the live output before the stream is returned. + */ +describe('executeProviderRequest — streaming cost policy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false }) + }) + + afterEach(resetEnvFlagsMock) + + function makeStreamingExecution(initialCost?: Record) { + return { + stream: new ReadableStream(), + execution: { + success: true, + output: { + content: '', + model: 'claude-opus-4-6', + tokens: { input: 0, output: 0, total: 0 }, + ...(initialCost ? { cost: initialCost } : {}), + }, + logs: [], + }, + } + } + + it('applies the cost multiplier to cost the provider writes while streaming', async () => { + envFlagsMockFns.getCostMultiplier.mockReturnValue(2) + const streaming = makeStreamingExecution() + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + stream: true, + }) + + streaming.execution.output.cost = { input: 1, output: 2, total: 3 } + + expect(streaming.execution.output.cost).toMatchObject({ input: 2, output: 4, total: 6 }) + }) + + it('does not charge for models Sim does not host', async () => { + const streaming = { + stream: new ReadableStream(), + execution: { + success: true, + output: { + content: '', + model: 'llama-3.3-70b-versatile', + tokens: { input: 0, output: 0, total: 0 }, + }, + logs: [], + }, + } + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('groq', { + model: 'llama-3.3-70b-versatile', + workspaceId: 'ws-1', + stream: true, + }) + + streaming.execution.output.cost = { input: 0.5, output: 1.5, total: 2 } + + expect(streaming.execution.output.cost).toMatchObject({ input: 0, output: 0, total: 0 }) + }) + + it('keeps tool cost from a settled stream that priced its tools before returning', async () => { + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-byok', isBYOK: true }) + const streaming = makeStreamingExecution({ + input: 0.01, + output: 0.02, + total: 0.035, + toolCost: 0.005, + }) + mockExecuteRequest.mockResolvedValue(streaming) + + await executeProviderRequest('anthropic', { + model: 'claude-opus-4-6', + workspaceId: 'ws-1', + stream: true, + }) + + expect(streaming.execution.output.cost).toMatchObject({ + input: 0, + output: 0, + total: 0.005, + toolCost: 0.005, + }) + }) +}) diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index b75860a1b11..9fc2c5593a2 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -1,8 +1,16 @@ 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 { + applyModelCostPolicy, + applySegmentCostPolicy, + calculateBillableModelCost, + installStreamingCostPolicy, + type ModelCostPolicy, + resolveModelCostPolicy, + withoutToolCost, +} from '@/providers/cost-policy' import { attachLargeFileRemoteUrls, uploadLargeFilesToProvider, @@ -10,10 +18,9 @@ import { import { getProviderExecutor } from '@/providers/registry' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' import { - calculateCost, generateStructuredOutputInstructions, - shouldBillModelUsage, sumToolCosts, + supportsPromptCaching, supportsReasoningEffort, supportsTemperature, supportsThinking, @@ -48,6 +55,10 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest { sanitizedRequest.thinkingLevel = undefined } + if (model && !supportsPromptCaching(model)) { + sanitizedRequest.promptCaching = undefined + } + return sanitizedRequest } @@ -59,71 +70,26 @@ 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. + * Applies the shared model-cost policy to a streaming response. * - * Also zeroes any per-segment model cost already written by trace enrichers - * (which run synchronously before streaming begins for all current providers). + * The streaming and non-streaming paths must charge identically for the same + * model and tokens, but streaming providers write their cost from inside the + * stream drain — long after this function returns — so the policy is installed + * on the live output object rather than applied to a value. */ -function zeroCostForBYOK(response: StreamingExecution): void { - const output = response.execution?.output as - | (Record & { - providerTiming?: { - timeSegments?: Array<{ - type?: string - cost?: { input?: number; output?: number; total?: number } - }> - } - }) - | undefined +function applyStreamingCostPolicy(response: StreamingExecution, policy: ModelCostPolicy): void { + const output = response.execution?.output if (!output || typeof output !== 'object') { - logger.warn('zeroCostForBYOK: output not available at intercept time; cost may not be zeroed') + logger.warn('Streaming output unavailable at intercept time; cost policy not applied') return } - let toolCost = 0 - Object.defineProperty(output, 'cost', { - get: () => (toolCost > 0 ? { ...ZERO_COST, toolCost, total: toolCost } : ZERO_COST), - set: (value: Record) => { - if (value?.toolCost && typeof value.toolCost === 'number') { - toolCost = value.toolCost - } - }, - configurable: true, - enumerable: true, - }) + installStreamingCostPolicy(output, policy) const segments = output.providerTiming?.timeSegments if (Array.isArray(segments)) { - zeroModelSegmentCosts(segments) + applySegmentCostPolicy(segments, policy) } } @@ -201,9 +167,7 @@ export async function executeProviderRequest( if (isStreamingExecution(response)) { logger.info('Provider returned StreamingExecution', { isBYOK }) - if (isBYOK) { - zeroCostForBYOK(response) - } + applyStreamingCostPolicy(response, resolveModelCostPolicy(sanitizedRequest.model, isBYOK)) return response } @@ -212,54 +176,51 @@ export async function executeProviderRequest( return response } + const costPolicy = resolveModelCostPolicy(response.model, isBYOK) + 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 + + /** + * Any provider that reports cache buckets also prices itself, because only + * it knows the tiers involved — Anthropic's 5m vs 1h writes cannot be + * reconstructed from a single `cacheWrite` count. Its cost is therefore + * authoritative and only the policy is applied on top. The fallback prices + * providers that report no cache usage at all. + * + * Tool cost is stripped either way: it is re-derived from `toolResults` + * below and must not be counted twice. + */ + response.cost = response.cost + ? (applyModelCostPolicy(withoutToolCost(response.cost), costPolicy) as typeof response.cost) + : calculateBillableModelCost(response.model, promptTokens, completionTokens, { isBYOK }) + + if (!costPolicy.billable) { + logger.info( + isBYOK + ? `Not billing model usage for ${response.model} - workspace BYOK key used` + : `Not billing model usage for ${response.model} - user provided API key or not hosted model` ) - } 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) + // Per-segment model costs are written by trace enrichers regardless of key + // provenance. Align them with the block-level decision so the displayed + // breakdown does not contradict the authoritative block cost. + if (response.timing?.timeSegments) { + applySegmentCostPolicy(response.timing.timeSegments, costPolicy) } const toolCost = sumToolCosts(response.toolResults) if (toolCost > 0 && response.cost) { - response.cost.toolCost = toolCost - response.cost.total += toolCost + // Replaced rather than mutated: a provider-supplied cost can be the same + // object it also handed to a time segment, and tool cost belongs only to + // the block total. + response.cost = { + ...response.cost, + toolCost, + total: response.cost.total + toolCost, + } } return response diff --git a/apps/sim/providers/kimi/index.ts b/apps/sim/providers/kimi/index.ts index 8343321e79c..a211c33a9bf 100644 --- a/apps/sim/providers/kimi/index.ts +++ b/apps/sim/providers/kimi/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -11,7 +12,10 @@ import { getProviderDefaultModel, getProviderModels, } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -312,7 +316,7 @@ export const kimiProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -348,6 +352,9 @@ export const kimiProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -367,31 +374,21 @@ export const kimiProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - const assistantReasoning = ( - currentResponse.choices[0]?.message as { reasoning_content?: string } | undefined - )?.reasoning_content - - currentMessages.push({ - role: 'assistant', - content: null, - ...(assistantReasoning ? { reasoning_content: assistantReasoning } : {}), - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -402,10 +399,12 @@ export const kimiProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -508,12 +507,56 @@ export const kimiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { model: request.model, provider: 'kimi' } ) + + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + } + finalPayload.tools = undefined + finalPayload.tool_choice = undefined + + const finalModelStartTime = Date.now() + currentResponse = await kimi.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration + + 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 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'kimi' } + ) + iterationCount++ + } } } catch (error) { logger.error('Error in Kimi request:', { error }) @@ -521,23 +564,8 @@ export const kimiProvider: ProviderConfig = { } if (request.stream) { - logger.info('Using streaming for final Kimi response after tool processing') - - const streamingPayload: any = { - ...payload, - messages: currentMessages, - stream: true, - stream_options: { include_usage: true }, - } - streamingPayload.tools = undefined - streamingPayload.tool_choice = undefined - - const streamResponse = await kimi.chat.completions.create( - streamingPayload, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) const streamingResult = createStreamingExecution({ model: request.model, @@ -559,8 +587,8 @@ export const kimiProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -571,32 +599,11 @@ export const kimiProvider: ProviderConfig = { : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromKimiStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (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, - } - } - ), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -633,6 +640,10 @@ export const kimiProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/litellm/index.test.ts b/apps/sim/providers/litellm/index.test.ts index 04efada3fa3..50563bf21db 100644 --- a/apps/sim/providers/litellm/index.test.ts +++ b/apps/sim/providers/litellm/index.test.ts @@ -72,19 +72,30 @@ vi.mock('@/providers/utils', () => ({ })) import { litellmProvider } from '@/providers/litellm' +import type { AgentStreamEvent } from '@/providers/stream-events' 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 } + reasoning_content?: string } -function chat({ content = null, toolCalls, usage }: ChatOptions = {}) { +function chat({ + content = null, + toolCalls, + usage, + reasoning_content: reasoningContent, +}: ChatOptions = {}) { return { choices: [ { - message: { content, tool_calls: toolCalls }, + message: { + content, + tool_calls: toolCalls, + ...(reasoningContent !== undefined ? { reasoning_content: reasoningContent } : {}), + }, finish_reason: toolCalls ? 'tool_calls' : 'stop', }, ], @@ -107,6 +118,16 @@ function run(request: Record) { const firstPayload = () => mockCreate.mock.calls[0][0] const lastPayload = () => mockCreate.mock.calls.at(-1)![0] +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + describe('litellmProvider.executeRequest', () => { beforeEach(() => { vi.clearAllMocks() @@ -190,12 +211,13 @@ describe('litellmProvider.executeRequest', () => { expect(result.content).toBe('{"answer":1}') }) - it('defers response_format into the final streaming call while keeping tools', async () => { + it('uses one distinct non-streaming extraction call for deferred response_format', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) ) .mockResolvedValueOnce(chat({ content: 'mid' })) + .mockResolvedValueOnce(chat({ content: '{"answer":1}' })) const result = await run({ stream: true, @@ -204,12 +226,46 @@ describe('litellmProvider.executeRequest', () => { }) const final = lastPayload() - expect(final.stream).toBe(true) + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(final.stream).toBeUndefined() 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) + expect(result.execution.output.content).toBe('{"answer":1}') + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + await expect(readAgentEvents(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: '{"answer":1}', turn: 'final' }, + ]) + }) + + it('projects a normal settled tool-loop answer without regeneration', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }] }) + ) + .mockResolvedValueOnce(chat({ content: 'done' })) + + const result = await run({ stream: true, tools: [tool('known')] }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 10, output: 6, total: 16 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect(readAgentEvents(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: 'done', turn: 'final' }, + ]) }) it('threads assistant tool_calls and a named tool response, and reports toolCalls', async () => { @@ -238,7 +294,31 @@ describe('litellmProvider.executeRequest', () => { expect(result.content).toBe('done') }) - it('emits a stub tool response for an unanswered tool_call_id', async () => { + it('replays LiteLLM assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + chat({ + content: 'I will use the tool.', + toolCalls: [{ id: 'c1', function: { name: 'known', arguments: '{}' } }], + reasoning_content: 'Normalized reasoning.', + }) + ) + .mockResolvedValueOnce(chat({ content: 'done' })) + + await run({ tools: [tool('known')] }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning_content: 'Normalized reasoning.', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'known', arguments: '{}' } }], + }) + }) + + it('emits a structured response when the requested tool is unavailable', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'cX', function: { name: 'ghost', arguments: '{}' } }] }) @@ -254,7 +334,7 @@ describe('litellmProvider.executeRequest', () => { expect(toolMsg.content).toContain('not available') }) - it('executes a tool with empty arguments without failing', async () => { + it('rejects empty tool arguments without executing the tool', async () => { mockCreate .mockResolvedValueOnce( chat({ toolCalls: [{ id: 'c1', function: { name: 'ping', arguments: '' } }] }) @@ -263,9 +343,9 @@ describe('litellmProvider.executeRequest', () => { await run({ tools: [tool('ping')] }) - expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect(mockExecuteTool).not.toHaveBeenCalled() const toolMsg = mockCreate.mock.calls[1][0].messages.find((m: any) => m.role === 'tool') - expect(toolMsg.content).not.toContain('"error":true') + expect(toolMsg.content).toContain('"error":true') }) it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { @@ -275,8 +355,10 @@ describe('litellmProvider.executeRequest', () => { await run({ tools: [tool('known')] }) - expect(mockCreate).toHaveBeenCalledTimes(1 + 20) + expect(mockCreate).toHaveBeenCalledTimes(1 + 20 + 1) expect(mockExecuteTool).toHaveBeenCalledTimes(20) + expect(lastPayload().tools).toBeUndefined() + expect(lastPayload().tool_choice).toBeUndefined() }) it('returns a streaming execution when streaming without active tools', async () => { diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index e8ee2229e6e..0135aac64b2 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { env } from '@/lib/core/config/env' @@ -8,7 +9,10 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromLiteLLMStream } from '@/providers/litellm/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -349,12 +353,25 @@ export const litellmProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = toolCall.function.arguments - ? JSON.parse(toolCall.function.arguments) - : {} + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -372,6 +389,9 @@ export const litellmProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -391,28 +411,21 @@ export const litellmProvider: ProviderConfig = { } }) - 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() - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -423,10 +436,12 @@ export const litellmProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -451,21 +466,6 @@ export const litellmProvider: ProviderConfig = { 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 @@ -538,92 +538,12 @@ export const litellmProvider: ProviderConfig = { ) } - 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, - streamFormat: 'agent-events-v1', - 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 - } - + /** + * Deferred structured output is a distinct extraction step, not streaming + * regeneration. Some LiteLLM backends cannot combine schema output with tools. + */ if (deferResponseFormat && responseFormatPayload) { - logger.info('Applying deferred JSON schema response format after tool processing') + logger.info('Applying deferred JSON schema extraction after tool processing') const finalFormatStartTime = Date.now() const finalPayload: any = { @@ -653,7 +573,6 @@ export const litellmProvider: ProviderConfig = { 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 @@ -666,6 +585,93 @@ export const litellmProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'litellm' } ) + } else if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await litellm.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'litellm' } + ) + } + + if (request.stream) { + logger.info('Projecting settled response after tool processing') + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) } const providerEndTime = Date.now() @@ -685,7 +691,7 @@ export const litellmProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -714,6 +720,10 @@ export const litellmProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index ecd8b141481..f4acdd81be0 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -7,7 +8,9 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMetaStream } from '@/providers/meta/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -22,7 +25,6 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, - trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -149,6 +151,7 @@ export const metaProvider: ProviderConfig = { // 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 + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload } @@ -204,9 +207,6 @@ export const metaProvider: ProviderConfig = { } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] let currentResponse = await meta.chat.completions.create( payload, @@ -225,7 +225,6 @@ export const metaProvider: ProviderConfig = { const toolResults: Record[] = [] const currentMessages = [...formattedMessages] let iterationCount = 0 - let hasUsedForcedTool = false let modelTime = firstResponseTime let toolsTime = 0 @@ -239,23 +238,6 @@ export const metaProvider: ProviderConfig = { }, ] - 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) { @@ -282,7 +264,7 @@ export const metaProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) // Every tool_call in the assistant message must be answered by a matching @@ -321,6 +303,9 @@ export const metaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -340,7 +325,7 @@ export const metaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -355,11 +340,9 @@ export const metaProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -370,10 +353,12 @@ export const metaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -407,48 +392,12 @@ export const metaProvider: ProviderConfig = { 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 @@ -476,111 +425,66 @@ export const metaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { 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 - ) + if (cappedToolCalls?.length) { + const { tools: _omittedTools, ...finalPayload } = payload + finalPayload.messages = currentMessages + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await meta.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromMetaStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (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, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - 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, - } - } - ), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'meta' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Meta request:', { error }) + throw error } // 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) { + // Meta rejects tool_choice: "none", so `tools` is dropped from this payload instead. + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -625,6 +529,52 @@ export const metaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -642,7 +592,7 @@ export const metaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -656,6 +606,10 @@ export const metaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/mistral/index.test.ts b/apps/sim/providers/mistral/index.test.ts new file mode 100644 index 00000000000..ec5871ad47a --- /dev/null +++ b/apps/sim/providers/mistral/index.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderToolConfig } from '@/providers/types' + +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('@/providers', () => ({ MAX_TOOL_ITERATIONS: 20 })) +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: (messages: unknown) => messages, +})) +vi.mock('@/providers/mistral/utils', () => ({ + createReadableStreamFromMistralStream: vi.fn(), +})) +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(() => 'mistral-large-latest'), +})) +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) => ({ + tools, + toolChoice: 'auto', + forcedTools: [], + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +import { mistralProvider } from '@/providers/mistral' + +function makeTool(id: string): ProviderToolConfig { + return { + id, + name: id, + description: '', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + +describe('mistralProvider.executeRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteTool.mockResolvedValue({ success: true, output: { ok: true } }) + }) + + it('projects the settled tool-loop answer without a final streaming request', async () => { + mockCreate + .mockResolvedValueOnce({ + choices: [ + { + message: { + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: 'done', tool_calls: undefined } }], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }) + + const result = await mistralProvider.executeRequest!({ + model: 'mistral-large-latest', + apiKey: 'key', + messages: [{ role: 'user', content: 'Use a tool' }], + stream: true, + tools: [makeTool('lookup')], + }) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('done') + expect(result.execution.output.tokens).toEqual({ input: 6, output: 3, total: 9 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'done', turn: 'final' }]) + }) +}) diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 605967bb546..ddf625a4596 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -7,7 +8,9 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromMistralStream } from '@/providers/mistral/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -139,6 +142,11 @@ export const mistralProvider: ProviderConfig = { if (request.stream && (!tools || tools.length === 0)) { logger.info('Using streaming response for Mistral request') + /** + * Mistral reports stream usage on the terminal chunk on its own and + * rejects `stream_options` with HTTP 422 (`extra_forbidden`), so the + * opt-in every other OpenAI-compatible provider sends is omitted here. + */ const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, stream: true, @@ -271,10 +279,25 @@ export const mistralProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -292,6 +315,9 @@ export const mistralProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -311,7 +337,7 @@ export const mistralProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', content: null, @@ -325,11 +351,9 @@ export const mistralProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -340,10 +364,12 @@ export const mistralProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -434,29 +460,55 @@ export const mistralProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'mistral' } ) + + if (currentResponse.choices[0]?.message?.tool_calls?.length) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await mistral.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'mistral' } + ) + } } if (request.stream) { - logger.info('Using streaming for final response after tool processing') + logger.info('Projecting settled response after tool processing') const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. - */ - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: currentMessages, - tool_choice: 'none', - stream: true, - } - const streamResponse = await mistral.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) - - const streamingResult = createStreamingExecution({ + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -465,7 +517,7 @@ export const mistralProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -476,7 +528,8 @@ export const mistralProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -486,34 +539,12 @@ export const mistralProvider: ProviderConfig = { } : undefined, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromMistralStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('Mistral final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || 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, - } - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -533,7 +564,7 @@ export const mistralProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -547,6 +578,10 @@ export const mistralProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index 56eddcbeb6b..e8deb2a9311 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -5,10 +5,64 @@ import { describe, expect, it } from 'vitest' import { getBaseModelProviders, getHostedModels, + getModelsWithPromptCaching, + getPromptCachingMinimumTokens, + getThinkingStreamVisibility, isModelDeprecated, orderModelIdsByReleaseDate, PROVIDER_DEFINITIONS, } from '@/providers/models' +import { supportsPromptCaching } from '@/providers/utils' + +describe('Anthropic thinking stream visibility', () => { + it('classifies visible Claude thinking as summarized rather than raw', () => { + for (const providerId of ['anthropic', 'azure-anthropic'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + if (model.capabilities.thinking) { + expect(getThinkingStreamVisibility(model.id)).toBe('summary') + } + } + } + }) +}) + +describe('Meta thinking stream visibility', () => { + it('classifies private Muse reasoning as not streamed', () => { + expect(getThinkingStreamVisibility('muse-spark-1.1')).toBe('none') + }) +}) + +describe('prompt caching capability', () => { + const cachingModels = new Set(getModelsWithPromptCaching()) + + it('covers every Claude model on both Anthropic surfaces', () => { + for (const providerId of ['anthropic', 'azure-anthropic'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + expect(cachingModels.has(model.id)).toBe(true) + } + } + }) + + /** + * OpenAI and Gemini cache automatically with no caller control, so declaring + * the capability would put a switch in the UI that does nothing. + */ + it('excludes providers whose caching is automatic', () => { + for (const providerId of ['openai', 'google'] as const) { + for (const model of PROVIDER_DEFINITIONS[providerId].models) { + expect(cachingModels.has(model.id)).toBe(false) + } + } + expect(supportsPromptCaching('gpt-5.5')).toBe(false) + }) + + it('reports the vendor minimum prefix, raised for Haiku', () => { + expect(getPromptCachingMinimumTokens('claude-sonnet-5')).toBe(1024) + expect(getPromptCachingMinimumTokens('claude-haiku-4-5')).toBe(2048) + expect(getPromptCachingMinimumTokens('azure-anthropic/claude-haiku-4-5')).toBe(2048) + expect(getPromptCachingMinimumTokens('gpt-5.5')).toBeNull() + }) +}) const DYNAMIC_PROVIDERS = new Set([ 'ollama', diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index c103bf2b083..aac911d4d5b 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -54,6 +54,18 @@ export interface ModelCapabilities { verbosity?: { values: string[] } + /** + * Model accepts caller-placed prompt-cache breakpoints, so caching is a real + * opt-in with a cost tradeoff (writes carry a premium over base input). + * + * Absent for providers whose caching is automatic and free — OpenAI and + * Gemini implicit caching need no switch, and exposing one would imply a + * control that does not exist. + */ + promptCaching?: { + /** Prefixes shorter than this are silently not cached by the vendor. */ + minimumCacheableTokens: number + } thinking?: { levels: string[] default?: string @@ -754,6 +766,9 @@ export const PROVIDER_DEFINITIONS: Record = { color: '#D97757', capabilities: { toolUsageControl: true, + // Every Claude model accepts cache_control breakpoints; Haiku raises the + // minimum prefix and overrides this per-model. + promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ { @@ -873,7 +888,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -894,7 +909,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -915,7 +930,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -935,7 +950,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -956,7 +971,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -978,7 +993,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -998,7 +1013,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1017,10 +1032,11 @@ export const PROVIDER_DEFINITIONS: Record = { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, maxOutputTokens: 64000, + promptCaching: { minimumCacheableTokens: 2048 }, thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1371,6 +1387,8 @@ export const PROVIDER_DEFINITIONS: Record = { isReseller: true, capabilities: { toolUsageControl: true, + // Microsoft Foundry supports the same cache_control breakpoints. + promptCaching: { minimumCacheableTokens: 1024 }, }, models: [ { @@ -1388,7 +1406,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high', 'max'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 1000000, @@ -1409,7 +1427,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1430,7 +1448,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1450,7 +1468,7 @@ export const PROVIDER_DEFINITIONS: Record = { thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1469,10 +1487,11 @@ export const PROVIDER_DEFINITIONS: Record = { temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, maxOutputTokens: 64000, + promptCaching: { minimumCacheableTokens: 2048 }, thinking: { levels: ['low', 'medium', 'high'], default: 'high', - streamed: 'full', + streamed: 'summary', }, }, contextWindow: 200000, @@ -1935,7 +1954,7 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'deepseek', name: 'DeepSeek', description: "DeepSeek's chat models", - defaultModel: 'deepseek-chat', + defaultModel: 'deepseek-v4-flash', modelPatterns: [], icon: DeepseekIcon, color: '#4D6BFE', @@ -1952,10 +1971,14 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-06-16', }, capabilities: { + reasoningEffort: { + values: ['high', 'max'], + }, thinking: { - levels: ['enabled'], + levels: ['none', 'enabled'], default: 'enabled', }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2026-04-24', @@ -1970,10 +1993,14 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + reasoningEffort: { + values: ['high', 'max'], + }, thinking: { - levels: ['enabled'], + levels: ['none', 'enabled'], default: 'enabled', }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2026-04-24', @@ -1988,10 +2015,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, - thinking: { - levels: ['enabled'], - default: 'enabled', - }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2024-12-26', @@ -2038,10 +2062,14 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-06-11', }, capabilities: { + reasoningEffort: { + values: ['high', 'max'], + }, thinking: { levels: ['enabled'], default: 'enabled', }, + maxOutputTokens: 384000, }, contextWindow: 1000000, releaseDate: '2025-01-20', @@ -4401,6 +4429,33 @@ export function getThinkingCapability( return null } +/** + * Get all models that accept caller-placed prompt-cache breakpoints. + * + * Reads merged provider+model capabilities because prompt caching is declared + * once per provider (every Claude model supports it) with per-model overrides + * only for the minimum prefix length. + */ +export function getModelsWithPromptCaching(): string[] { + const models: string[] = [] + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + for (const model of provider.models) { + if (model.capabilities.promptCaching ?? provider.capabilities?.promptCaching) { + models.push(model.id) + } + } + } + return models +} + +/** + * Minimum prefix length the model will cache, or `null` when the model does + * not support caller-placed breakpoints. + */ +export function getPromptCachingMinimumTokens(modelId: string): number | null { + return getModelCapabilities(modelId)?.promptCaching?.minimumCacheableTokens ?? null +} + /** * Get all models that support thinking capability */ @@ -4428,8 +4483,9 @@ export function getThinkingLevelsForModel(modelId: string): string[] | null { /** * Per-provider defaults for thinking stream visibility, used when a model does * not declare `capabilities.thinking.streamed` explicitly. Gemini and OpenAI - * stream summaries only; Bedrock never requests reasoning; OpenAI-compat - * vendors that expose reasoning stream the raw chain of thought. + * stream summaries only; Bedrock and Meta do not expose reasoning text; + * OpenAI-compatible vendors that expose reasoning stream the raw chain of + * thought. */ const PROVIDER_THINKING_STREAM_DEFAULTS: Record = { google: 'summary', @@ -4437,6 +4493,7 @@ const PROVIDER_THINKING_STREAM_DEFAULTS: Record t.id === toolName) if (!tool) { @@ -298,6 +303,9 @@ export const nvidiaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -317,26 +325,21 @@ export const nvidiaProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -347,10 +350,12 @@ export const nvidiaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -453,104 +458,67 @@ export const nvidiaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { 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 - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + } + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.parallel_tool_calls = false + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await nvidia.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromNvidiaStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (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, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - 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, - } - } - ), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'nvidia' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in NVIDIA NIM request:', { error }) + throw error } - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -596,6 +564,52 @@ export const nvidiaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -613,7 +627,7 @@ export const nvidiaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -627,6 +641,10 @@ export const nvidiaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/ollama-cloud/index.test.ts b/apps/sim/providers/ollama-cloud/index.test.ts index 1164e0be3e3..e199c2ebbf3 100644 --- a/apps/sim/providers/ollama-cloud/index.test.ts +++ b/apps/sim/providers/ollama-cloud/index.test.ts @@ -64,7 +64,11 @@ vi.mock('@/providers/ollama-cloud/utils', () => ({ onComplete: (content: string, usage: StreamUsage) => void ) => { streamOnComplete.current = onComplete - return 'OLLAMA_CLOUD_STREAM' + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, })) vi.mock('@/providers/utils', () => ({ @@ -79,10 +83,11 @@ vi.mock('@/providers/utils', () => ({ vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) import { ollamaCloudProvider } from '@/providers/ollama-cloud' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface StreamingResult { - stream: string + stream: string | ReadableStream execution: { output: { content: string @@ -97,10 +102,23 @@ interface StreamingResult { type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } } function completion( - opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {} + opts: { + content?: string | null + toolCalls?: ToolCallChunk[] + usage?: StreamUsage + reasoning?: string + } = {} ) { return { - choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }], + choices: [ + { + message: { + content: opts.content ?? null, + tool_calls: opts.toolCalls, + ...(opts.reasoning !== undefined ? { reasoning: opts.reasoning } : {}), + }, + }, + ], usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, } } @@ -116,6 +134,16 @@ function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): Provide } } +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + const baseRequest: ProviderRequest = { model: 'ollama-cloud/gpt-oss:120b', messages: [{ role: 'user', content: 'hi' }], @@ -234,6 +262,41 @@ describe('ollamaCloudProvider.executeRequest', () => { }) }) + it('replays Ollama Cloud assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'done' })) + + await ollamaCloudProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'mytool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without aborting the loop', async () => { mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' }) mockCreate @@ -321,7 +384,7 @@ describe('ollamaCloudProvider.executeRequest', () => { stream: true, })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_CLOUD_STREAM') + expect(result.stream).toBeInstanceOf(ReadableStream) expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) expect(result.execution.output.model).toBe('gpt-oss:120b') @@ -334,7 +397,7 @@ describe('ollamaCloudProvider.executeRequest', () => { 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 () => { + it('projects the settled tool-loop answer without a regeneration call', async () => { mockCreate .mockResolvedValueOnce( completion({ @@ -343,7 +406,7 @@ describe('ollamaCloudProvider.executeRequest', () => { ], }) ) - .mockResolvedValueOnce(completion({ content: 'intermediate' })) + .mockResolvedValueOnce(completion({ content: 'final answer' })) const result = (await ollamaCloudProvider.executeRequest({ ...baseRequest, @@ -351,19 +414,14 @@ describe('ollamaCloudProvider.executeRequest', () => { tools: [makeTool('mytool')], })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_CLOUD_STREAM') + expect(mockCreate).toHaveBeenCalledTimes(2) 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.tokens).toEqual({ input: 10, output: 6, total: 16 }) expect(result.execution.output.toolCalls).toMatchObject({ count: 1 }) + expect(result.stream).toBeInstanceOf(ReadableStream) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) }) }) diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index c41134fac80..c3ba7a2a097 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -1,5 +1,6 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk, @@ -9,8 +10,11 @@ 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 { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' import type { AgentStreamEvent } from '@/providers/stream-events' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' @@ -63,8 +67,8 @@ export interface OllamaCoreConfig { /** * 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'`. + * as `tool_choice: 'auto'` (forced tools degrade to auto). Tool-disabled calls + * drop tools entirely rather than relying on `tool_choice: 'none'`. */ export async function executeOllamaProviderRequest( request: ProviderRequest, @@ -288,10 +292,25 @@ export async function executeOllamaProviderRequest( const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -309,6 +328,9 @@ export async function executeOllamaProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -328,26 +350,21 @@ export async function executeOllamaProviderRequest( } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -358,10 +375,12 @@ export async function executeOllamaProviderRequest( toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -440,91 +459,10 @@ export async function executeOllamaProviderRequest( ) } - 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, - streamFormat: 'agent-events-v1', - 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. + /** + * Deferred structured output is a distinct extraction step, not streaming + * regeneration. Ollama cannot combine its JSON mode reliably with tool use. + */ if (request.responseFormat && hasActiveTools) { const finalPayload: any = { model: payload.model } if (payload.temperature !== undefined) finalPayload.temperature = payload.temperature @@ -566,6 +504,92 @@ export async function executeOllamaProviderRequest( finalResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: providerId } ) + } else if ( + iterationCount === MAX_TOOL_ITERATIONS && + currentResponse.choices[0]?.message?.tool_calls?.length + ) { + /** + * The capped turn still requests tools, so make one tool-disabled call to + * synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await ollama.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: providerId } + ) + } + + if (request.stream) { + logger.info(`Projecting settled ${providerLabel} response after tool processing`) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) } const providerEndTime = Date.now() @@ -585,7 +609,7 @@ export async function executeOllamaProviderRequest( modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -614,6 +638,10 @@ export async function executeOllamaProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/ollama/index.test.ts b/apps/sim/providers/ollama/index.test.ts index 00ec2b43c32..a0c358d7559 100644 --- a/apps/sim/providers/ollama/index.test.ts +++ b/apps/sim/providers/ollama/index.test.ts @@ -51,7 +51,11 @@ vi.mock('@/providers/ollama/utils', () => ({ onComplete: (content: string, usage: StreamUsage) => void ) => { streamOnComplete.current = onComplete - return 'OLLAMA_STREAM' + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, })) vi.mock('@/providers/utils', () => ({ @@ -69,10 +73,11 @@ vi.mock('@/stores/providers', () => ({ })) import { ollamaProvider } from '@/providers/ollama' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface StreamingResult { - stream: string + stream: string | ReadableStream execution: { output: { content: string @@ -85,10 +90,23 @@ interface StreamingResult { type ToolCallChunk = { id: string; type: 'function'; function: { name: string; arguments: string } } function completion( - opts: { content?: string | null; toolCalls?: ToolCallChunk[]; usage?: StreamUsage } = {} + opts: { + content?: string | null + toolCalls?: ToolCallChunk[] + usage?: StreamUsage + reasoning?: string + } = {} ) { return { - choices: [{ message: { content: opts.content ?? null, tool_calls: opts.toolCalls } }], + choices: [ + { + message: { + content: opts.content ?? null, + tool_calls: opts.toolCalls, + ...(opts.reasoning !== undefined ? { reasoning: opts.reasoning } : {}), + }, + }, + ], usage: opts.usage ?? { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, } } @@ -104,6 +122,16 @@ function makeTool(id: string, usageControl?: 'auto' | 'force' | 'none'): Provide } } +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + const baseRequest: ProviderRequest = { model: 'llama3.2', messages: [{ role: 'user', content: 'hi' }], @@ -230,6 +258,41 @@ describe('ollamaProvider.executeRequest', () => { }) }) + it('replays Ollama assistant content and emitted reasoning on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{"x":1}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'done' })) + + await ollamaProvider.executeRequest({ + ...baseRequest, + tools: [makeTool('mytool')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'mytool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without aborting the loop', async () => { mockExecuteTool.mockResolvedValue({ success: false, error: 'boom' }) mockCreate @@ -321,7 +384,7 @@ describe('ollamaProvider.executeRequest', () => { stream: true, })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_STREAM') + expect(result.stream).toBeInstanceOf(ReadableStream) expect(mockCreate.mock.calls[0][0].stream_options).toEqual({ include_usage: true }) streamOnComplete.current?.('streamed text', { @@ -348,7 +411,7 @@ describe('ollamaProvider.executeRequest', () => { expect(result.execution.output.content).toBe('{"a":1}') }) - it('streams the final response after a tool loop, carrying tool calls', async () => { + it('projects the settled tool-loop answer without a regeneration call', async () => { mockCreate .mockResolvedValueOnce( completion({ @@ -357,7 +420,7 @@ describe('ollamaProvider.executeRequest', () => { ], }) ) - .mockResolvedValueOnce(completion({ content: 'intermediate' })) + .mockResolvedValueOnce(completion({ content: 'final answer' })) const result = (await ollamaProvider.executeRequest({ ...baseRequest, @@ -365,19 +428,56 @@ describe('ollamaProvider.executeRequest', () => { tools: [makeTool('mytool')], })) as unknown as StreamingResult - expect(result.stream).toBe('OLLAMA_STREAM') + expect(mockCreate).toHaveBeenCalledTimes(2) 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.tokens).toEqual({ input: 10, output: 6, total: 16 }) expect(result.execution.output.toolCalls).toMatchObject({ count: 1 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + expect(result.stream).toBeInstanceOf(ReadableStream) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) + }) + + it('retains one distinct structured extraction call after a streamed tool loop', async () => { + mockCreate + .mockResolvedValueOnce( + completion({ + toolCalls: [ + { id: 'call_1', type: 'function', function: { name: 'mytool', arguments: '{}' } }, + ], + }) + ) + .mockResolvedValueOnce(completion({ content: 'unstructured answer' })) + .mockResolvedValueOnce(completion({ content: '```json\n{"a":1}\n```' })) + + const result = (await ollamaProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [makeTool('mytool')], + responseFormat: { name: 'r', schema: { type: 'object' } }, + })) as unknown as StreamingResult + + expect(mockCreate).toHaveBeenCalledTimes(3) + const extractionCall = mockCreate.mock.calls[2][0] + expect(extractionCall.stream).toBeUndefined() + expect(extractionCall.response_format).toEqual({ type: 'json_object' }) + expect(extractionCall.tools).toBeUndefined() + expect(result.execution.output.content).toBe('{"a":1}') + expect(result.execution.output.providerTiming?.iterations).toBe(3) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(3) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: '{"a":1}', turn: 'final' }]) }) }) diff --git a/apps/sim/providers/openai-compat/assistant-history.ts b/apps/sim/providers/openai-compat/assistant-history.ts new file mode 100644 index 00000000000..24b4b1d002b --- /dev/null +++ b/apps/sim/providers/openai-compat/assistant-history.ts @@ -0,0 +1,74 @@ +export type OpenAICompatReasoningField = 'reasoning' | 'reasoning_content' + +interface OpenAICompatResponseAssistantMessage { + content: string | null +} + +interface OpenAICompatResponseAssistantReasoning { + reasoning?: string + reasoning_content?: string +} + +interface OpenAICompatToolCall { + id: string + function: { + name: string + arguments: string + } +} + +export interface OpenAICompatAssistantHistoryMessage { + [key: string]: unknown + role: 'assistant' + content: string | null + tool_calls: Array<{ + id: string + type: 'function' + function: { + name: string + arguments: string + } + }> + reasoning?: string + reasoning_content?: string +} + +interface CreateOpenAICompatAssistantHistoryOptions { + message: OpenAICompatResponseAssistantMessage + toolCalls: readonly OpenAICompatToolCall[] + reasoningFields: readonly OpenAICompatReasoningField[] +} + +/** + * Replays an OpenAI-compatible assistant tool turn without replacing provider + * content or inventing reasoning fields the provider did not emit. + */ +export function createOpenAICompatAssistantHistory({ + message, + toolCalls, + reasoningFields, +}: CreateOpenAICompatAssistantHistoryOptions): OpenAICompatAssistantHistoryMessage { + const reasoningMessage = message as OpenAICompatResponseAssistantMessage & + OpenAICompatResponseAssistantReasoning + const history: OpenAICompatAssistantHistoryMessage = { + role: 'assistant', + content: message.content, + tool_calls: toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: 'function', + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }, + })), + } + + for (const field of reasoningFields) { + const value = reasoningMessage[field] + if (typeof value === 'string') { + history[field] = value + } + } + + return history +} diff --git a/apps/sim/providers/openai-compat/stream-events.test.ts b/apps/sim/providers/openai-compat/stream-events.test.ts index b1d2b31f5e5..74fb5305d2e 100644 --- a/apps/sim/providers/openai-compat/stream-events.test.ts +++ b/apps/sim/providers/openai-compat/stream-events.test.ts @@ -153,4 +153,18 @@ describe('createOpenAICompatibleAgentEventStream', () => { ).toBe('Hello world') expect(onComplete.mock.calls[0][0].content).toBe('Hello world') }) + + it('surfaces documented in-band provider errors', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield { + error: { message: 'Upstream provider failed' }, + choices: [], + } as any + })(), + { providerName: 'OpenRouter' } + ) + + await expect(collectEvents(stream)).rejects.toThrow('Upstream provider failed') + }) }) diff --git a/apps/sim/providers/openai-compat/stream-events.ts b/apps/sim/providers/openai-compat/stream-events.ts index cf4369bb120..9d5ef438279 100644 --- a/apps/sim/providers/openai-compat/stream-events.ts +++ b/apps/sim/providers/openai-compat/stream-events.ts @@ -12,6 +12,10 @@ import { createLogger } from '@sim/logger' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' +import { + getOpenRouterReasoningDetailText, + type OpenRouterReasoningDetail, +} from '@/providers/openrouter/reasoning' import type { AgentStreamEvent, TextDeltaTurn } from '@/providers/stream-events' import { ensureToolCallId } from '@/providers/tool-call-id' @@ -28,6 +32,8 @@ export interface OpenAICompatStreamComplete { reasoning_content?: string /** Groq-style reasoning field accumulated from deltas. */ reasoning?: string + /** OpenRouter reasoning blocks accumulated without reordering or normalization. */ + reasoning_details?: OpenRouterReasoningDetail[] usage: CompletionUsage /** Assembled when emitToolCallStarts is true (id + name + args). */ toolCalls?: OpenAICompatAssembledToolCall[] @@ -55,6 +61,24 @@ type CompatChunkDelta = ChatCompletionChunk.Choice.Delta & { reasoning?: string | { text?: string } } +export type OpenRouterChatCompletionChunk = ChatCompletionChunk & { + choices: Array< + ChatCompletionChunk.Choice & { + delta: CompatChunkDelta & { + reasoning_details?: OpenRouterReasoningDetail[] + } + } + > +} + +interface CompatStreamExtension { + error?: string | { message?: string } + x_groq?: { + usage?: CompletionUsage + error?: string | { message?: string } + } +} + function extractDeltaReasoning(delta: CompatChunkDelta | undefined): { text: string reasoning_content?: string @@ -89,6 +113,8 @@ export function createOpenAICompatibleAgentEventStream( ): ReadableStream { const { providerName, turn = 'final', emitToolCallStarts = false, onComplete } = options const streamLogger = createLogger(`${providerName}Utils`) + let cancelled = false + let streamIterator: AsyncIterator | undefined return new ReadableStream({ async start(controller) { @@ -96,6 +122,7 @@ export function createOpenAICompatibleAgentEventStream( let fullThinking = '' let reasoningContent = '' let reasoning = '' + const reasoningDetails: OpenRouterReasoningDetail[] = [] let promptTokens = 0 let completionTokens = 0 let totalTokens = 0 @@ -107,15 +134,29 @@ export function createOpenAICompatibleAgentEventStream( >() try { - for await (const chunk of stream) { + streamIterator = stream[Symbol.asyncIterator]() + while (true) { + const next = await streamIterator.next() + if (next.done || cancelled) break + const chunk = next.value + const extension = chunk as ChatCompletionChunk & CompatStreamExtension + if (extension.error) { + const message = + typeof extension.error === 'string' ? extension.error : extension.error.message + throw new Error(message || `${providerName} stream error`) + } + if (extension.x_groq?.error) { + const message = + typeof extension.x_groq.error === 'string' + ? extension.x_groq.error + : extension.x_groq.error.message + throw new Error(message || 'Groq stream error') + } /** * Groq puts stream usage under `x_groq.usage` on the final chunk * instead of the OpenAI `usage` field; accept either shape. */ - const usage = - chunk.usage ?? - (chunk as { x_groq?: { usage?: CompletionUsage } }).x_groq?.usage ?? - undefined + const usage = chunk.usage ?? extension.x_groq?.usage if (usage) { promptTokens = usage.prompt_tokens ?? 0 completionTokens = usage.completion_tokens ?? 0 @@ -127,13 +168,29 @@ export function createOpenAICompatibleAgentEventStream( finishReason = choice.finish_reason } const delta: CompatChunkDelta | undefined = choice?.delta + const openRouterDelta = (chunk as OpenRouterChatCompletionChunk).choices?.[0]?.delta + const chunkReasoningDetails = Array.isArray(openRouterDelta?.reasoning_details) + ? openRouterDelta.reasoning_details + : [] + let structuredThinking = '' + for (const detail of chunkReasoningDetails) { + reasoningDetails.push(detail) + const text = getOpenRouterReasoningDetailText(detail) + if (text) { + structuredThinking += text + controller.enqueue({ type: 'thinking_delta', text }) + } + } + fullThinking += structuredThinking const extracted = extractDeltaReasoning(delta) if (extracted.text) { - fullThinking += extracted.text if (extracted.reasoning_content) reasoningContent += extracted.reasoning_content if (extracted.reasoning) reasoning += extracted.reasoning - controller.enqueue({ type: 'thinking_delta', text: extracted.text }) + if (!structuredThinking) { + fullThinking += extracted.text + controller.enqueue({ type: 'thinking_delta', text: extracted.text }) + } } const content = typeof delta?.content === 'string' ? delta.content : '' @@ -182,6 +239,7 @@ export function createOpenAICompatibleAgentEventStream( } } + if (cancelled) return if (onComplete) { if (promptTokens === 0 && completionTokens === 0) { streamLogger.warn(`${providerName} stream completed without usage data`) @@ -202,6 +260,7 @@ export function createOpenAICompatibleAgentEventStream( thinking: fullThinking, ...(reasoningContent ? { reasoning_content: reasoningContent } : {}), ...(reasoning ? { reasoning } : {}), + ...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}), usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, @@ -214,8 +273,14 @@ export function createOpenAICompatibleAgentEventStream( controller.close() } catch (error) { - controller.error(error) + if (!cancelled) { + controller.error(error) + } } }, + async cancel() { + cancelled = true + await streamIterator?.return?.() + }, }) } diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts index 80e263b9b81..b08b4050cba 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -1,11 +1,18 @@ /** * @vitest-environment node */ + +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' import { beforeEach, describe, expect, it, vi } from 'vitest' import { openaiCompatToolCallStartChunks } from '@/providers/__fixtures__/openai-compat' -import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' +import type { OpenRouterChatCompletionChunk } from '@/providers/openai-compat/stream-events' +import { + createOpenAICompatStreamingToolLoopStream, + type OpenAICompatCreateCompletion, +} from '@/providers/openai-compat/streaming-tool-loop' import type { AgentStreamEvent } from '@/providers/stream-events' -import type { TimeSegment } from '@/providers/types' +import type { ProviderToolConfig, TimeSegment } from '@/providers/types' const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ mockExecuteTool: vi.fn(), @@ -80,6 +87,7 @@ function toolThenAnswerChunks(toolName: string, args: string, answer: string) { { choices: [ { + finish_reason: 'tool_calls', delta: { tool_calls: [{ index: 0, function: { arguments: args } }], }, @@ -91,6 +99,21 @@ function toolThenAnswerChunks(toolName: string, args: string, answer: string) { ] as const } +function openRouterChunk( + delta: OpenRouterChatCompletionChunk['choices'][number]['delta'], + finishReason: ChatCompletionChunk.Choice['finish_reason'] = null, + usage?: CompletionUsage +): OpenRouterChatCompletionChunk { + return { + id: 'chunk', + object: 'chat.completion.chunk', + created: 0, + model: 'openrouter/test-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + usage, + } +} + describe('createOpenAICompatStreamingToolLoopStream', () => { const logger = { info: vi.fn(), @@ -127,7 +150,12 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'done', reasoning_content: 'final thought' } }], + choices: [ + { + delta: { content: 'done', reasoning_content: 'final thought' }, + finish_reason: 'stop', + }, + ], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -163,6 +191,133 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { role: 'assistant', reasoning_content: 'I should call the tool. ', }) + const modelSegments = timeSegments.filter((segment) => segment.type === 'model') + expect(modelSegments[0]).toMatchObject({ + thinkingContent: 'I should call the tool. ', + finishReason: 'tool_calls', + tokens: { input: 5, output: 3, total: 8 }, + provider: 'deepseek', + toolCalls: [{ id: 'call_1', name: 'lookup', arguments: {} }], + }) + expect(modelSegments[1]).toMatchObject({ + assistantContent: 'done', + thinkingContent: 'final thought', + finishReason: 'stop', + tokens: { input: 8, output: 2, total: 10 }, + provider: 'deepseek', + }) + }) + + it('replays interleaved OpenRouter reasoning_details unchanged and in order', async () => { + const messageHistory: unknown[][] = [] + const lookupTool: ProviderToolConfig = { + id: 'lookup', + name: 'lookup', + description: 'Looks up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, + } + const reasoningDetails = [ + { + type: 'reasoning.text', + text: 'Inspect the request. ', + signature: null, + id: 'reasoning-1', + format: 'anthropic-claude-v1', + index: 0, + }, + { + type: 'reasoning.encrypted', + data: 'opaque-data', + id: 'reasoning-2', + format: 'anthropic-claude-v1', + index: 1, + }, + { + type: 'reasoning.summary', + summary: 'Use the lookup result.', + id: 'reasoning-3', + format: 'anthropic-claude-v1', + index: 2, + }, + ] as const + let call = 0 + + const createStream: OpenAICompatCreateCompletion = vi.fn(async (params) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield openRouterChunk({ reasoning_details: [reasoningDetails[0]] }) + yield openRouterChunk({ + content: 'I will look that up.', + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '' }, + }, + ], + }) + yield openRouterChunk({ reasoning_details: [reasoningDetails[1]] }) + yield openRouterChunk( + { + reasoning_details: [reasoningDetails[2]], + tool_calls: [{ index: 0, function: { arguments: '{}' } }], + }, + 'tool_calls', + { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } + ) + })() + } + return (async function* () { + yield openRouterChunk({ content: 'done' }, 'stop', { + prompt_tokens: 8, + completion_tokens: 2, + total_tokens: 10, + }) + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenRouter', + request: { + model: 'openrouter/anthropic/claude-sonnet-4', + apiKey: 'k', + messages: [], + tools: [lookupTool], + }, + basePayload: { model: 'anthropic/claude-sonnet-4' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(events.filter((event) => event.type === 'thinking_delta')).toEqual([ + { type: 'thinking_delta', text: 'Inspect the request. ' }, + { type: 'thinking_delta', text: 'Use the lookup result.' }, + ]) + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (message) => message.role === 'assistant' && Array.isArray(message.tool_calls) + ) + expect(assistantWithTools).toEqual({ + role: 'assistant', + content: 'I will look that up.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + }, + ], + reasoning_details: reasoningDetails, + }) }) it('omits reasoning_content when preserveAssistantReasoning is false', async () => { @@ -179,7 +334,7 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'done' } }], + choices: [{ delta: { content: 'done' }, finish_reason: 'stop' }], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -226,7 +381,7 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'final answer' } }], + choices: [{ delta: { content: 'final answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -283,7 +438,7 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { } return (async function* () { yield { - choices: [{ delta: { content: 'recovered' } }], + choices: [{ delta: { content: 'recovered' }, finish_reason: 'stop' }], usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, } })() @@ -319,6 +474,129 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { expect(createStream).toHaveBeenCalledTimes(2) }) + it.each(['null', '[]', '"text"', '0', 'false'])( + 'does not execute tools with non-object arguments: %s', + async (argumentsJson) => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', argumentsJson, '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'recovered' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(createStream).toHaveBeenCalledTimes(2) + } + ) + + it('fails an unexpected tool AbortError and reports completed usage', async () => { + const createStream = vi.fn(async () => { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + }) + mockExecuteTool.mockRejectedValueOnce(new DOMException('cancelled', 'AbortError')) + + const onComplete = vi.fn() + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete, + }) + + await expect(collectEvents(stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(createStream).toHaveBeenCalledTimes(1) + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ tokens: { input: 5, output: 3, total: 8 } }) + ) + }) + + it.each([ + [false, 'false'], + [0, '0'], + ['', '""'], + [null, 'null'], + ] as const)('replays successful falsy tool output %j', async (output, expectedContent) => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + mockExecuteTool.mockResolvedValueOnce({ success: true, output }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + const secondPayload = createStream.mock.calls[1][0] as { + messages: Array<{ role: string; content?: string }> + } + expect(secondPayload.messages).toContainEqual( + expect.objectContaining({ role: 'tool', content: expectedContent }) + ) + }) + it('streams thinking live and assembles tool args from deltas', async () => { let call = 0 const createStream = vi.fn(async () => { @@ -336,11 +614,14 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { ], usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 }, } + yield { + choices: [{ delta: {}, finish_reason: 'tool_calls' }], + } })() } return (async function* () { yield { - choices: [{ delta: { content: 'fetched' } }], + choices: [{ delta: { content: 'fetched' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, } })() @@ -372,4 +653,118 @@ describe('createOpenAICompatStreamingToolLoopStream', () => { expect.not.objectContaining({ skipPostProcess: true }) ) }) + + it('finalizes truncated text when finish_reason is length without a tool call', async () => { + const createStream = vi.fn(async () => + (async function* () { + yield { + choices: [{ delta: { content: 'Truncated answer' }, finish_reason: 'length' }], + usage: { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 }, + } + })() + ) + const onComplete = vi.fn() + const timeSegments: TimeSegment[] = [] + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenAI', + request: { model: 'gpt-4.1', apiKey: 'k', messages: [] }, + basePayload: { model: 'gpt-4.1', max_tokens: 9 }, + messages: [{ role: 'user', content: 'answer' }], + createStream: createStream as never, + logger, + timeSegments, + onComplete, + }) + ) + + expect(events).toEqual([ + { type: 'text_delta', text: 'Truncated answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Truncated answer', + tokens: { input: 7, output: 9, total: 16 }, + iterations: 1, + }) + ) + expect(timeSegments).toHaveLength(1) + expect(timeSegments[0]).toMatchObject({ + type: 'model', + finishReason: 'length', + assistantContent: 'Truncated answer', + }) + }) + + it('rejects a length-capped turn containing a partial tool call', async () => { + const createStream = vi.fn(async () => + (async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'call_partial', + type: 'function', + function: { name: 'lookup', arguments: '{"query":' }, + }, + ], + }, + finish_reason: null, + }, + ], + } + yield { + choices: [{ delta: {}, finish_reason: 'length' }], + usage: { prompt_tokens: 7, completion_tokens: 9, total_tokens: 16 }, + } + })() + ) + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'OpenAI', + request: { + model: 'gpt-4.1', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as never], + }, + basePayload: { model: 'gpt-4.1', max_tokens: 9 }, + messages: [{ role: 'user', content: 'answer' }], + createStream: createStream as never, + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + let streamError: unknown + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch (error) { + streamError = error + } + + expect(streamError).toEqual(new Error('OpenAI returned tool calls with finish_reason length')) + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'call_partial', + name: 'lookup', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'call_partial', + name: 'lookup', + status: 'error', + }) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts index 170a6dbe25d..7f766317be7 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -13,6 +13,7 @@ import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import { MAX_TOOL_ITERATIONS } from '@/providers' @@ -20,12 +21,16 @@ import { createOpenAICompatibleAgentEventStream, type OpenAICompatAssembledToolCall, } from '@/providers/openai-compat/stream-events' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { isAbortError, + parseToolArguments, type StreamingToolLoopComplete, settleOpenTools, + terminateToolLoop, } from '@/providers/streaming-tool-loop-shared' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' import { calculateCost, @@ -85,6 +90,16 @@ export function createOpenAICompatStreamingToolLoopStream( preserveAssistantReasoning = false, } = options const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let activeEventReader: ReadableStreamDefaultReader | undefined + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } return new ReadableStream({ async start(controller) { @@ -100,24 +115,49 @@ export function createOpenAICompatStreamingToolLoopStream( const toolCalls: unknown[] = [] const toolResults: Record[] = [] const openToolStarts = new Map() - const streamOpts = request.abortSignal ? { signal: request.abortSignal } : undefined + const streamOpts = { signal: loopAbortController.signal } let currentToolChoice = basePayload.tool_choice let usedForcedTools: string[] = [] let hasUsedForcedTool = false + const reportProgress = () => { + const modelCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: modelCost.input, + output: modelCost.output, + total: modelCost.total + (toolCost || 0), + ...(toolCost ? { toolCost } : {}), + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } try { - while (iterationCount < MAX_TOOL_ITERATIONS) { - if (request.abortSignal?.aborted) { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { settleOpenTools(controller, openToolStarts, 'cancelled') throw new DOMException('Stream aborted', 'AbortError') } const modelStart = Date.now() + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS const turnPayload = { ...basePayload, messages: currentMessages, - ...(currentToolChoice !== undefined ? { tool_choice: currentToolChoice } : {}), + ...(finalSynthesis + ? { tools: undefined, tool_choice: 'none' } + : currentToolChoice !== undefined + ? { tool_choice: currentToolChoice } + : {}), stream: true as const, } @@ -130,11 +170,26 @@ export function createOpenAICompatStreamingToolLoopStream( let turnContent = '' let turnReasoningContent = '' let turnReasoning = '' + let turnReasoningDetails: OpenRouterReasoningDetail[] | undefined let turnFinishReason: string | undefined let assembledTools: OpenAICompatAssembledToolCall[] = [] const liveText: string[] = [] + let sawToolCallDelta = false + const inspectedStream = (async function* () { + for await (const chunk of stream) { + if ( + chunk.choices.some( + (choice) => + Array.isArray(choice.delta.tool_calls) && choice.delta.tool_calls.length > 0 + ) + ) { + sawToolCallDelta = true + } + yield chunk + } + })() - const eventStream = createOpenAICompatibleAgentEventStream(stream, { + const eventStream = createOpenAICompatibleAgentEventStream(inspectedStream, { providerName, emitToolCallStarts: true, onComplete: (result) => { @@ -146,6 +201,7 @@ export function createOpenAICompatStreamingToolLoopStream( turnContent = result.content || '' turnReasoningContent = result.reasoning_content || '' turnReasoning = result.reasoning || '' + turnReasoningDetails = result.reasoning_details turnFinishReason = result.finishReason assembledTools = result.toolCalls ?? [] }, @@ -153,6 +209,7 @@ export function createOpenAICompatStreamingToolLoopStream( { const reader = eventStream.getReader() + activeEventReader = reader while (true) { const { done, value } = await reader.read() if (done) break @@ -168,23 +225,37 @@ export function createOpenAICompatStreamingToolLoopStream( controller.enqueue({ type: 'text_delta', text: value.text, turn: 'pending' }) } } + activeEventReader = undefined } - /** - * Only execute tools when the turn completed normally. A `length` - * finish means the stream truncated mid-generation — assembled tool - * arguments would be partial JSON. - */ - const toolsExecutable = turnFinishReason !== 'length' const assembledPendingTools = assembledTools.filter((tc) => tc.id && tc.function?.name) - if (assembledPendingTools.length > 0 && !toolsExecutable) { - logger.warn('Skipping tool execution for truncated turn', { + if (turnFinishReason === undefined) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} stream ended without finish_reason`) + } + if (finalSynthesis && assembledPendingTools.length > 0) { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} returned tool calls during final synthesis`) + } + if (assembledPendingTools.length > 0 && turnFinishReason !== 'tool_calls') { + settleOpenTools(controller, openToolStarts, 'error') + throw new Error( + `${providerName} returned tool calls with finish_reason ${turnFinishReason}` + ) + } + const cappedTextTurn = + turnFinishReason === 'length' && !sawToolCallDelta && openToolStarts.size === 0 + if ( + assembledPendingTools.length === 0 && + turnFinishReason !== 'stop' && + !cappedTextTurn + ) { + logger.warn('Rejecting incomplete model turn', { finishReason: turnFinishReason, - toolCount: assembledPendingTools.length, }) - settleOpenTools(controller, openToolStarts, 'error') + throw new Error(`${providerName} stream ended with finish_reason ${turnFinishReason}`) } - const pendingTools = toolsExecutable ? assembledPendingTools : [] + const pendingTools = assembledPendingTools const turnTag = pendingTools.length > 0 ? 'intermediate' : 'final' const turnText = turnContent || liveText.join('') // If the parser assembled text but we somehow missed deltas, still emit @@ -193,10 +264,7 @@ export function createOpenAICompatStreamingToolLoopStream( controller.enqueue({ type: 'text_delta', text: turnText, turn: 'pending' }) } controller.enqueue({ type: 'turn_end', turn: turnTag }) - if (turnText) { - // Keep the latest turn's text so a MAX_TOOL_ITERATIONS exit still has content. - content = turnText - } + content = turnText const modelEnd = Date.now() const thisModelTime = modelEnd - modelStart @@ -210,6 +278,31 @@ export function createOpenAICompatStreamingToolLoopStream( endTime: modelEnd, duration: thisModelTime, }) + enrichLastModelSegmentFromChatCompletions( + timeSegments, + { + choices: [ + { + message: { + content: turnText, + tool_calls: pendingTools, + ...(turnReasoningContent ? { reasoning_content: turnReasoningContent } : {}), + ...(turnReasoning ? { reasoning: turnReasoning } : {}), + ...(turnReasoningDetails?.length + ? { reasoning_details: turnReasoningDetails } + : {}), + }, + finish_reason: turnFinishReason, + }, + ], + usage: turnUsage, + }, + pendingTools, + { + model: request.model, + provider: providerName.toLowerCase(), + } + ) tokens.input += turnUsage.prompt_tokens tokens.output += turnUsage.completion_tokens tokens.total += @@ -240,9 +333,10 @@ export function createOpenAICompatStreamingToolLoopStream( const assistantHistory: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam & { reasoning_content?: string reasoning?: string + reasoning_details?: OpenRouterReasoningDetail[] } = { role: 'assistant', - content: turnText || null, + content: turnText, tool_calls: pendingTools, } if (preserveAssistantReasoning) { @@ -253,6 +347,9 @@ export function createOpenAICompatStreamingToolLoopStream( assistantHistory.reasoning = turnReasoning } } + if (turnReasoningDetails?.length) { + assistantHistory.reasoning_details = turnReasoningDetails + } currentMessages.push(assistantHistory) const toolsStartTime = Date.now() @@ -266,14 +363,10 @@ export function createOpenAICompatStreamingToolLoopStream( * with defaulted `{}` args could fire side effects with missing * parameters. Fail the call and let the model react to the error. */ - let toolArgs: Record = {} - let argsParseError = false + let toolArgs: Record try { - toolArgs = JSON.parse(tc.function.arguments || '{}') - } catch { - argsParseError = true - } - if (argsParseError) { + toolArgs = parseToolArguments(tc.function.arguments, toolName) + } catch (error) { const endTime = Date.now() openToolStarts.delete(toolUseId) controller.enqueue({ @@ -290,7 +383,7 @@ export function createOpenAICompatStreamingToolLoopStream( result: { success: false as const, output: undefined, - error: `Invalid tool arguments JSON for ${toolName}`, + error: getErrorMessage(error, `Invalid tool arguments for ${toolName}`), }, startTime: toolCallStartTime, endTime, @@ -300,7 +393,7 @@ export function createOpenAICompatStreamingToolLoopStream( } try { - if (request.abortSignal?.aborted) { + if (loopAbortController.signal.aborted) { throw new DOMException('Stream aborted', 'AbortError') } const tool = request.tools?.find((t) => t.id === toolName) @@ -336,7 +429,7 @@ export function createOpenAICompatStreamingToolLoopStream( request ) const result = await executeTool(toolName, executionParams, { - signal: request.abortSignal, + signal: loopAbortController.signal, }) const toolCallEndTime = Date.now() const value = { @@ -359,11 +452,29 @@ export function createOpenAICompatStreamingToolLoopStream( }) return value } catch (error) { - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - if (!cancelled) { - logger.error('Error processing tool call:', { error, toolName }) - } const toolCallEndTime = Date.now() + if (loopAbortController.signal.aborted) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'cancelled', + }) + throw error + } + if (isAbortError(error)) { + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + throw error + } + + logger.error('Error processing tool call:', { error, toolName }) const value = { toolUseId, toolName, @@ -377,7 +488,7 @@ export function createOpenAICompatStreamingToolLoopStream( startTime: toolCallStartTime, endTime: toolCallEndTime, duration: toolCallEndTime - toolCallStartTime, - status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + status: 'error' as ToolCallEndStatus, } openToolStarts.delete(toolUseId) controller.enqueue({ @@ -402,9 +513,11 @@ export function createOpenAICompatStreamingToolLoopStream( }) let resultContent: unknown - if (value.result.success && value.result.output) { - toolResults.push(value.result.output as Record) - resultContent = value.result.output + if (value.result.success) { + if (isRecordLike(value.result.output)) { + toolResults.push(value.result.output) + } + resultContent = value.result.output ?? null } else { resultContent = { error: true, @@ -448,39 +561,35 @@ export function createOpenAICompatStreamingToolLoopStream( iterationCount++ } - /** - * MAX_TOOL_ITERATIONS exit: every turn was tagged intermediate, so the - * answer channel would otherwise be empty. Flush the last turn's text - * as the final answer so legacy consumers still receive content. - */ - if (!sawFinalTurn && content) { - controller.enqueue({ type: 'text_delta', text: content, turn: 'final' }) + if (!sawFinalTurn) { + throw new Error(`${providerName} tool loop ended without a final response`) } - const modelCost = calculateCost(request.model, tokens.input, tokens.output) - const toolCostTotal = sumToolCosts(toolResults) - onComplete({ - content, - tokens, - cost: { - input: modelCost.input, - output: modelCost.output, - total: modelCost.total + (toolCostTotal || 0), - ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), - }, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, - modelTime, - toolsTime, - firstResponseTime, - iterations: modelCalls, - }) + reportProgress() controller.close() } catch (error) { - const cancelled = isAbortError(error) || !!request.abortSignal?.aborted - settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') - controller.error(toError(error)) + reportProgress() + terminateToolLoop({ + controller, + openTools: openToolStarts, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error(`${providerName} streaming tool loop failed`, { + error: toError(cause).message, + }), + }) + } finally { + activeEventReader = undefined + request.abortSignal?.removeEventListener('abort', abortFromRequest) } }, + async cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + await activeEventReader?.cancel(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, }) } diff --git a/apps/sim/providers/openai/core.cache-key.test.ts b/apps/sim/providers/openai/core.cache-key.test.ts new file mode 100644 index 00000000000..c12bfa34928 --- /dev/null +++ b/apps/sim/providers/openai/core.cache-key.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + * + * OpenAI prompt caching is automatic, so the only lever is routing stickiness: + * a `prompt_cache_key` that is stable for one agent block and distinct between + * blocks. Sharing a key across blocks with different prefixes would lower the + * hit rate rather than raise it. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeResponsesProviderRequest } from '@/providers/openai/core' +import type { ProviderRequest } from '@/providers/types' + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/utils', () => ({ + calculateCost: () => ({ input: 0, output: 0, total: 0 }), + sumToolCosts: () => 0, + enforceStrictSchema: (schema: unknown) => schema, + prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), + prepareToolsWithUsageControl: (tools: unknown[]) => ({ + tools, + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), + supportsReasoningEffort: () => false, +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +const COMPLETED_RESPONSE = { + id: 'resp_1', + status: 'completed', + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, +} + +describe('executeResponsesProviderRequest prompt cache key', () => { + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + // A Response body reads once, so each call needs its own instance. + fetchMock = vi.fn( + async () => + new Response(JSON.stringify(COMPLETED_RESPONSE), { + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + async function sentCacheKey(request: Partial): Promise { + await executeResponsesProviderRequest( + { + model: 'gpt-5.5', + apiKey: 'k', + messages: [{ role: 'user', content: 'hi' }], + ...request, + }, + { + providerId: 'openai', + providerLabel: 'OpenAI', + modelName: 'gpt-5.5', + endpoint: 'https://api.openai.com/v1/responses', + headers: { Authorization: 'Bearer k' }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never, + fetch: fetchMock as unknown as typeof fetch, + } + ) + const body = JSON.parse(fetchMock.mock.calls.at(-1)?.[1].body as string) + return body.prompt_cache_key + } + + it('sends the same key for repeat runs of one block', async () => { + const first = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const second = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + + expect(first).toBeTruthy() + expect(second).toBe(first) + }) + + it('sends a different key for another block in the same workflow', async () => { + const blockA = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const blockB = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-b' }) + + expect(blockB).not.toBe(blockA) + }) + + it('sends a different key for the same block id in another workflow', async () => { + const workflowOne = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + const workflowTwo = await sentCacheKey({ workflowId: 'wf-2', blockId: 'block-a' }) + + expect(workflowTwo).not.toBe(workflowOne) + }) + + it('leaks no internal identifier into the key', async () => { + const key = await sentCacheKey({ workflowId: 'wf-1', blockId: 'block-a' }) + + expect(key).not.toContain('wf-1') + expect(key).not.toContain('block-a') + }) + + it('omits the key when the caller has no stable identity', async () => { + expect(await sentCacheKey({ workflowId: 'wf-1' })).toBeUndefined() + expect(await sentCacheKey({ blockId: 'block-a' })).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts index 06f5e926331..a9b588951b0 100644 --- a/apps/sim/providers/openai/core.reasoning.test.ts +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -7,6 +7,7 @@ * unverified-organization 400 falls back to a summary-free retry. */ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import type { BlockTokens } from '@/executor/types' import { executeResponsesProviderRequest } from '@/providers/openai/core' import type { ProviderRequest } from '@/providers/types' import { executeTool } from '@/tools' @@ -18,8 +19,8 @@ vi.mock('@/providers/utils', () => ({ sumToolCosts: () => 0, enforceStrictSchema: (schema: unknown) => schema, prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), - prepareToolsWithUsageControl: () => ({ - tools: [], + prepareToolsWithUsageControl: (tools: unknown[]) => ({ + tools, toolChoice: undefined, forcedTools: [], hasFilteredTools: false, @@ -37,6 +38,22 @@ function jsonResponse(body: unknown, status = 200) { }) } +function sseResponse(events: unknown[]) { + const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('') + return new Response(body, { headers: { 'Content-Type': 'text/event-stream' } }) +} + +async function collect(stream: ReadableStream) { + const events: unknown[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + const COMPLETED_RESPONSE = { id: 'resp_1', status: 'completed', @@ -61,6 +78,7 @@ describe('executeResponsesProviderRequest reasoning payload', () => { let fetchMock: ReturnType beforeEach(() => { + vi.clearAllMocks() fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE)) }) @@ -127,6 +145,46 @@ describe('executeResponsesProviderRequest reasoning payload', () => { expect((result as { content: string }).content).toBe('hello') }) + it('remembers summary rejection for later tool-loop turns', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse( + { + error: { + message: + "Your organization must be verified to generate reasoning summaries. (param: 'reasoning.summary')", + }, + }, + 400 + ) + ) + .mockResolvedValueOnce( + jsonResponse({ + id: 'resp_tool', + status: 'completed', + output: [ + { type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: '{}' }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }) + ) + .mockResolvedValueOnce(jsonResponse(COMPLETED_RESPONSE)) + ;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } }) + + await run({ + model: 'o3', + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string).reasoning).toEqual({ + summary: 'auto', + }) + expect(JSON.parse(fetchMock.mock.calls[1][1].body as string).reasoning).toBeUndefined() + expect(JSON.parse(fetchMock.mock.calls[2][1].body as string).reasoning).toBeUndefined() + }) + it('does not retry on unrelated 400s', async () => { fetchMock.mockResolvedValue( jsonResponse({ error: { message: 'Invalid value for input' } }, 400) @@ -164,75 +222,464 @@ describe('executeResponsesProviderRequest reasoning payload', () => { expect(body.reasoning).toBeUndefined() }) - describe('final regenerated stream after the tool loop', () => { - const TOOL_CALL_RESPONSE = { - id: 'resp_tool', - status: 'completed', - output: [{ type: 'function_call', call_id: 'call_1', name: 'exa_search', arguments: '{}' }], - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - } - - const SETTLED_ANSWER_RESPONSE = { - id: 'resp_answer', - status: 'completed', - output: [ - { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'Settled answer from loop' }], - }, - ], - usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, - } - - /** An SSE stream that ends without any output_text (dead function_call turn). */ - function emptySseResponse() { - return new Response('data: {"type":"response.completed"}\n\ndata: [DONE]\n\n', { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }) - } + describe('live streaming tool loop', () => { + it('streams reasoning and tool lifecycle in real time without a regeneration call', async () => { + const toolTurnResponse = { + id: 'resp_tool', + status: 'completed', + output: [ + { + id: 'rs_1', + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'I should search.' }], + }, + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, + } + const answerTurnResponse = { + id: 'resp_answer', + status: 'completed', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'Final answer', annotations: [] }], + }, + ], + usage: { input_tokens: 4, output_tokens: 5, total_tokens: 9 }, + } - async function collect(stream: ReadableStream) { + fetchMock + .mockResolvedValueOnce( + sseResponse([ + { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'I should search.', + }, + { + type: 'response.output_item.added', + output_index: 1, + sequence_number: 2, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + sequence_number: 3, + response: toolTurnResponse, + }, + ]) + ) + .mockResolvedValueOnce( + sseResponse([ + { + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_2', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'I have the result.', + }, + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 1, + content_index: 0, + sequence_number: 2, + delta: 'Final answer', + logprobs: [], + }, + { + type: 'response.completed', + sequence_number: 3, + response: answerTurnResponse, + }, + ]) + ) + let resolveTool!: (value: { success: true; output: { results: string[] } }) => void + ;(executeTool as Mock).mockReturnValue( + new Promise((resolve) => { + resolveTool = resolve + }) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream; execution: { output: { content: string } } } + + const reader = result.stream.getReader() const events: unknown[] = [] - const reader = stream.getReader() + for (let index = 0; index < 3; index++) { + const next = await reader.read() + expect(next.done).toBe(false) + events.push(next.value) + } + + expect(events).toEqual([ + { type: 'thinking_delta', text: 'I should search.' }, + { type: 'tool_call_start', id: 'call_1', name: 'exa_search' }, + { type: 'turn_end', turn: 'intermediate' }, + ]) + expect(fetchMock).toHaveBeenCalledTimes(1) + + resolveTool({ success: true, output: { results: ['hit'] } }) while (true) { - const { done, value } = await reader.read() - if (done) break - events.push(value) + const next = await reader.read() + if (next.done) break + events.push(next.value) } - return events - } - it('forces tool_choice none and keeps the tool-loop answer when the stream has no text', async () => { + expect(events).toEqual([ + { type: 'thinking_delta', text: 'I should search.' }, + { type: 'tool_call_start', id: 'call_1', name: 'exa_search' }, + { type: 'turn_end', turn: 'intermediate' }, + { type: 'tool_call_end', id: 'call_1', name: 'exa_search', status: 'success' }, + { type: 'thinking_delta', text: 'I have the result.' }, + { type: 'text_delta', text: 'Final answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(fetchMock).toHaveBeenCalledTimes(2) + const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body as string) + const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body as string) + expect(firstBody.stream).toBe(true) + expect(secondBody.stream).toBe(true) + expect(secondBody.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'rs_1', type: 'reasoning' }), + expect.objectContaining({ call_id: 'call_1', type: 'function_call' }), + expect.objectContaining({ call_id: 'call_1', type: 'function_call_output' }), + ]) + ) + expect(result.execution.output.content).toBe('Final answer') + }) + + it('fails an unexpected tool AbortError and preserves completed usage', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: 'resp_tool', + status: 'completed', + output: [ + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, + }, + }, + ]) + ) + ;(executeTool as Mock).mockRejectedValueOnce( + new DOMException('tool aborted unexpectedly', 'AbortError') + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { + stream: ReadableStream + execution: { output: { tokens: BlockTokens } } + } + + await expect(collect(result.stream)).rejects.toMatchObject({ name: 'AbortError' }) + expect(result.execution.output.tokens).toEqual({ + input: 2, + output: 3, + total: 5, + cacheRead: 0, + cacheWrite: 0, + }) + }) + + it('makes the final answer turn after the maximum tool batches', async () => { + for (let index = 0; index < 5; index++) { + const callId = `call_${index}` + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: `fc_${index}`, + type: 'function_call', + call_id: callId, + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: `resp_${index}`, + status: 'completed', + output: [ + { + id: `fc_${index}`, + type: 'function_call', + call_id: callId, + name: 'exa_search', + arguments: '{}', + status: 'completed', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + } + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_text.delta', + item_id: 'msg_final', + output_index: 0, + content_index: 0, + sequence_number: 1, + delta: 'Answer after five tools', + logprobs: [], + }, + { + type: 'response.completed', + sequence_number: 2, + response: { + id: 'resp_final', + status: 'completed', + output: [ + { + id: 'msg_final', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: 'Answer after five tools', annotations: [] }, + ], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) ;(executeTool as Mock).mockResolvedValue({ success: true, output: { results: [] } }) - fetchMock - .mockResolvedValueOnce(jsonResponse(TOOL_CALL_RESPONSE)) - .mockResolvedValueOnce(jsonResponse(SETTLED_ANSWER_RESPONSE)) - .mockResolvedValueOnce(emptySseResponse()) const result = (await run({ model: 'gpt-5.5', stream: true, + agentEvents: true, tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, })) as { stream: ReadableStream; execution: { output: { content: string } } } - expect(fetchMock).toHaveBeenCalledTimes(3) - const streamBody = JSON.parse(fetchMock.mock.calls[2][1].body as string) - expect(streamBody.stream).toBe(true) - // The regeneration exists to stream prose; streamed calls are never executed. - expect(streamBody.tool_choice).toBe('none') - - const events = await collect(result.stream) - // Settled chips for the silent loop's executed calls ride ahead of the answer. - expect(events[0]).toEqual({ type: 'tool_call_start', id: 'call_1', name: 'exa_search' }) - expect(events[1]).toEqual({ - type: 'tool_call_end', - id: 'call_1', - name: 'exa_search', - status: 'success', + await collect(result.stream) + + expect(fetchMock).toHaveBeenCalledTimes(6) + expect(executeTool).toHaveBeenCalledTimes(5) + const finalBody = JSON.parse(fetchMock.mock.calls[5][1].body as string) + expect(finalBody.tool_choice).toBe('none') + expect(finalBody.tools).toBeUndefined() + expect(result.execution.output.content).toBe('Answer after five tools') + }) + + it('finalizes truncated text when max_output_tokens is reached without a tool call', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_text.delta', + item_id: 'msg_partial', + output_index: 0, + content_index: 0, + sequence_number: 1, + delta: 'Partial answer', + logprobs: [], + }, + { + type: 'response.incomplete', + sequence_number: 2, + response: { + id: 'resp_incomplete', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { + stream: ReadableStream + execution: { + output: { + content: string + tokens: { input: number; output: number; total: number } + } + } + } + + await expect(collect(result.stream)).resolves.toEqual([ + { type: 'text_delta', text: 'Partial answer', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ]) + expect(result.execution.output).toMatchObject({ + content: 'Partial answer', + tokens: { input: 1, output: 1, total: 2 }, + }) + }) + + it('rejects a max_output_tokens turn containing a partial tool call', async () => { + fetchMock.mockResolvedValueOnce( + sseResponse([ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'exa_search', + arguments: '', + status: 'in_progress', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'fc_partial', + output_index: 0, + sequence_number: 2, + delta: '{"query":', + }, + { + type: 'response.incomplete', + sequence_number: 3, + response: { + id: 'resp_incomplete_tool', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [ + { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'exa_search', + arguments: '{"query":', + status: 'incomplete', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]) + ) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream } + + await expect(collect(result.stream)).rejects.toThrow( + 'OpenAI Responses stream incomplete: max_output_tokens' + ) + expect(executeTool).not.toHaveBeenCalled() + }) + + it('aborts the active Responses stream when its consumer cancels', async () => { + let requestSignal: AbortSignal | undefined + const encoder = new TextEncoder() + fetchMock.mockImplementation(async (_url: string, init: RequestInit) => { + requestSignal = init.signal as AbortSignal + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: 'response.reasoning_summary_text.delta', + item_id: 'rs_1', + output_index: 0, + summary_index: 0, + sequence_number: 1, + delta: 'Still working', + })}\n\n` + ) + ) + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } } + ) + }) + + const result = (await run({ + model: 'gpt-5.5', + stream: true, + agentEvents: true, + tools: [{ id: 'exa_search', name: 'exa_search', description: 'd', parameters: {} }] as any, + })) as { stream: ReadableStream } + + const reader = result.stream.getReader() + expect(await reader.read()).toEqual({ + done: false, + value: { type: 'thinking_delta', text: 'Still working' }, }) - expect(result.execution.output.content).toBe('Settled answer from loop') + + await reader.cancel('client disconnected') + + expect(requestSignal?.aborted).toBe(true) }) }) }) diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index d7cbce000a8..df4ca1b6cd0 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -1,20 +1,27 @@ +import { createHash } from 'node:crypto' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type OpenAI from 'openai' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' -import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { createOpenAIResponsesStreamingToolLoopStream } from '@/providers/openai/streaming-tool-loop' +import { enrichLastModelSegmentFromOpenAIResponse } from '@/providers/openai/trace' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' 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, supportsReasoningEffort, trackForcedToolUsage, } from '@/providers/utils' @@ -24,7 +31,6 @@ import { convertResponseOutputToInputItems, convertToolsToResponses, createReadableStreamFromResponses, - extractResponseReasoning, extractResponseText, extractResponseToolCalls, parseResponsesUsage, @@ -36,6 +42,22 @@ import { type PreparedTools = ReturnType type ToolChoice = PreparedTools['toolChoice'] +/** + * Stable routing key for OpenAI's prompt cache, scoped to one agent block. + * + * Per-block rather than per-workflow: two blocks in the same workflow have + * different prefixes, so sharing a key would pull them onto the same engine and + * lower the hit rate. Hashed so no internal identifier leaves the system. + * Returns `undefined` when the caller has no stable identity to key on. + */ +function buildPromptCacheKey(request: ProviderRequest): string | undefined { + if (!request.workflowId || !request.blockId) return undefined + return createHash('sha256') + .update(`${request.workflowId}:${request.blockId}`) + .digest('hex') + .slice(0, 32) +} + export interface ResponsesProviderConfig { providerId: string providerLabel: string @@ -97,6 +119,19 @@ export async function executeResponsesProviderRequest( model: config.modelName, } + /** + * OpenAI prompt caching is automatic and free, so there is nothing to toggle + * — but requests only hit a warm cache when they route to the same engine. + * A stable key per agent block sharpens that routing and is required for + * reliable matching on GPT-5.6+. + * + * `prompt_cache_key` is absent from the pinned SDK's typings, which is + * harmless: this body is a plain object posted through `fetch`, never + * `responses.create()`. Do not delete it as an unknown parameter. + */ + const promptCacheKey = buildPromptCacheKey(request) + if (promptCacheKey) basePayload.prompt_cache_key = promptCacheKey + if (request.temperature !== undefined) basePayload.temperature = request.temperature if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens @@ -127,11 +162,6 @@ export async function executeResponsesProviderRequest( } } - // 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 @@ -145,20 +175,11 @@ export async function executeResponsesProviderRequest( 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) ?? {}), - format: textFormat, - } - logger.info(`Added JSON schema response format to ${config.providerLabel} request`) + basePayload.text = { + ...((basePayload.text as Record) ?? {}), + format: textFormat, } + logger.info(`Added JSON schema response format to ${config.providerLabel} request`) } const tools = request.tools?.length @@ -247,14 +268,20 @@ export async function executeResponsesProviderRequest( : bodyRest } + let reasoningSummariesUnavailable = false + const fetchResponsesWithSummaryFallback = async ( - body: Record + requestedBody: Record, + abortSignal = request.abortSignal ): Promise => { + const body = reasoningSummariesUnavailable + ? (stripReasoningSummary(requestedBody) ?? requestedBody) + : requestedBody const response = await fetchImpl(config.endpoint, { method: 'POST', headers: config.headers, body: JSON.stringify(body), - signal: request.abortSignal, + signal: abortSignal, }) if (response.ok) return response @@ -266,6 +293,7 @@ export async function executeResponsesProviderRequest( throw new Error(`${config.providerLabel} API error (${response.status}): ${message}`) } + reasoningSummariesUnavailable = true logger.warn( `${config.providerLabel} rejected reasoning summaries (organization not verified); retrying without summary`, { model: config.modelName } @@ -274,7 +302,7 @@ export async function executeResponsesProviderRequest( method: 'POST', headers: config.headers, body: JSON.stringify(strippedBody), - signal: request.abortSignal, + signal: abortSignal, }) if (!retryResponse.ok) { const retryMessage = await parseErrorResponse(retryResponse) @@ -296,7 +324,58 @@ export async function executeResponsesProviderRequest( const providerStartTimeISO = new Date(providerStartTime).toISOString() try { - if (request.stream && (!tools || tools.length === 0)) { + const hasActiveTools = Array.isArray(basePayload.tools) && basePayload.tools.length > 0 + + if (request.stream && hasActiveTools) { + logger.info(`Using live streaming tool loop for ${config.providerLabel} request`) + const timeSegments: TimeSegment[] = [] + + 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 }) => + createOpenAIResponsesStreamingToolLoopStream({ + providerId: config.providerId, + providerLabel: config.providerLabel, + request, + initialInput, + initialToolChoice: responsesToolChoice, + forcedTools: preparedTools?.forcedTools, + createStream: (input, overrides, abortSignal) => + fetchResponsesWithSummaryFallback(createRequestBody(input, overrides), abortSignal), + logger, + timeSegments, + onComplete: (result) => { + 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() + }, + }), + }) + } + + if (request.stream && !hasActiveTools) { logger.info(`Using streaming response for ${config.providerLabel} request`) const streamResponse = await fetchResponsesWithSummaryFallback( @@ -313,23 +392,12 @@ export async function executeResponsesProviderRequest( streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { - output.content = content - output.tokens = { - input: usage?.promptTokens || 0, - output: usage?.completionTokens || 0, - total: usage?.totalTokens || 0, - } + const accumulator = createOpenAIUsageAccumulator() + addOpenAIUsage(accumulator, usage) - const costResult = calculateCost( - request.model, - usage?.promptTokens || 0, - usage?.completionTokens || 0 - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + output.content = content + output.tokens = buildOpenAIUsageTokens(accumulator) + output.cost = buildOpenAIUsageCost(request.model, accumulator) if (thinking) { const segment = output.providerTiming?.timeSegments?.[0] @@ -377,21 +445,11 @@ export async function executeResponsesProviderRequest( ) 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 usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, parseResponsesUsage(currentResponse.usage)) const toolCalls = [] const toolResults: Record[] = [] - /** - * Executed calls in completion order, for settled tool chips on the - * regenerated answer stream (the silent loop has no live stream to emit - * lifecycle events on while tools actually run). - */ - const toolLifecycle: Array<{ id: string; name: string; status: ToolCallEndStatus }> = [] let iterationCount = 0 let modelTime = firstResponseTime let toolsTime = 0 @@ -450,11 +508,24 @@ export async function executeResponsesProviderRequest( const toolName = toolCall.name try { - const toolArgs = toolCall.arguments ? JSON.parse(toolCall.arguments) : {} + const toolArgs = parseToolArguments(toolCall.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { - return null + 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) @@ -473,6 +544,9 @@ export async function executeResponsesProviderRequest( duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -492,13 +566,11 @@ export async function executeResponsesProviderRequest( } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) - - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue + const executionResults = await Promise.all(toolExecutionPromises) + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -509,10 +581,12 @@ export async function executeResponsesProviderRequest( toolCallId: toolCall.id, }) - let resultContent: Record - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output as Record + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -531,12 +605,6 @@ export async function executeResponsesProviderRequest( success: result.success, }) - toolLifecycle.push({ - id: toolCall.id, - name: toolName, - status: result.success ? 'success' : 'error', - }) - currentInput.push({ type: 'function_call_output', call_id: toolCall.id, @@ -596,12 +664,7 @@ export async function executeResponsesProviderRequest( modelTime += thisModelTime - const usage = parseResponsesUsage(currentResponse.usage) - if (usage) { - tokens.input += usage.promptTokens - tokens.output += usage.completionTokens - tokens.total += usage.totalTokens - } + addOpenAIUsage(usage, parseResponsesUsage(currentResponse.usage)) iterationCount++ } @@ -618,225 +681,6 @@ export async function executeResponsesProviderRequest( ) } - // 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 = { - model: config.modelName, - input: formattedInput, - text: { - ...((basePayload.text as Record) ?? {}), - 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 (supportsReasoningEffort(config.modelName) && basePayload.reasoning) { - finalPayload.reasoning = basePayload.reasoning - } - if (request.verbosity !== undefined && request.verbosity !== 'auto') { - finalPayload.text = { - ...((finalPayload.text as Record) ?? {}), - 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) - - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed function calls are never executed. With `tool_choice: 'auto'` - * a reasoning model can re-decide to call a tool here, ending the stream - * with a dead function_call and an empty answer. - */ - const streamOverrides: Record = { stream: true, tool_choice: 'none' } - if (deferredTextFormat) { - streamOverrides.text = { - ...((basePayload.text as Record) ?? {}), - format: deferredTextFormat, - } - } - - const streamResponse = await fetchResponsesWithSummaryFallback( - createRequestBody(currentInput, streamOverrides) - ) - - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => { - const answerStream = createReadableStreamFromResponses( - streamResponse, - (streamedContent, usage, thinking) => { - /** - * Belt-and-braces for the regeneration ending without text: keep - * the tool loop's settled answer instead of clobbering it with an - * empty string (clients then render it from the final envelope). - */ - if (!streamedContent && content) { - logger.warn( - `${config.providerLabel} final stream produced no text; keeping tool-loop answer` - ) - } - output.content = streamedContent || 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, - } - - if (thinking) { - const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') - if (lastModel) { - lastModel.thinkingContent = thinking - } - } - } - ) - - if (toolLifecycle.length === 0) { - return answerStream - } - - /** - * Settled tool chips ride ahead of the answer: the silent loop's - * calls already completed, so opted-in consumers get start+end pairs - * (name + status only) before the regenerated text streams. Runs - * without a sink never see these events (the byte projection ignores - * non-text), so legacy output is unchanged. - */ - const answerReader = answerStream.getReader() - return new ReadableStream({ - start(controller) { - for (const call of toolLifecycle) { - controller.enqueue({ type: 'tool_call_start', id: call.id, name: call.name }) - controller.enqueue({ - type: 'tool_call_end', - id: call.id, - name: call.name, - status: call.status, - }) - } - }, - async pull(controller) { - const { done, value } = await answerReader.read() - if (done) { - controller.close() - return - } - controller.enqueue(value) - }, - cancel(reason) { - return answerReader.cancel(reason) - }, - }) - }, - }) - - return streamingResult - } - const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -844,7 +688,13 @@ export async function executeResponsesProviderRequest( return { content, model: request.model, - tokens, + tokens: buildOpenAIUsageTokens(usage), + /** + * No tool cost here: `executeProviderRequest` re-derives it from + * `toolResults` for non-streaming responses, so folding it in would + * double-charge it. + */ + cost: buildOpenAIUsageCost(request.model, usage), toolCalls: toolCalls.length > 0 ? toolCalls : undefined, toolResults: toolResults.length > 0 ? toolResults : undefined, timing: { @@ -868,6 +718,10 @@ export async function executeResponsesProviderRequest( duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, @@ -875,82 +729,3 @@ export async function executeResponsesProviderRequest( }) } } - -/** - * 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, - }) -} diff --git a/apps/sim/providers/openai/streaming-tool-loop.ts b/apps/sim/providers/openai/streaming-tool-loop.ts new file mode 100644 index 00000000000..7f9a75788d7 --- /dev/null +++ b/apps/sim/providers/openai/streaming-tool-loop.ts @@ -0,0 +1,579 @@ +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import type OpenAI from 'openai' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { enrichLastModelSegmentFromOpenAIResponse } from '@/providers/openai/trace' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' +import { + extractResponseText, + extractResponseToolCalls, + isMaxOutputTokensIncompleteResponse, + isResponseFunctionCallEvent, + iterateResponsesStreamEvents, + parseResponsesUsage, + type ResponsesInputItem, + type ResponsesToolCall, + type ResponsesToolChoice, + responseContainsFunctionCall, +} from '@/providers/openai/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { + isAbortError, + parseToolArguments, + type StreamingToolLoopComplete, + settleOpenTools, + terminateToolLoop, +} from '@/providers/streaming-tool-loop-shared' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { executeTool } from '@/tools' + +export type CreateOpenAIResponsesStream = ( + input: ResponsesInputItem[], + overrides: Record, + abortSignal: AbortSignal +) => Promise + +type OpenAIStreamingToolLoopComplete = Omit & { + tokens: ReturnType +} + +export interface CreateOpenAIResponsesStreamingToolLoopOptions { + providerId: string + providerLabel: string + request: ProviderRequest + initialInput: ResponsesInputItem[] + initialToolChoice?: ResponsesToolChoice + forcedTools?: string[] + createStream: CreateOpenAIResponsesStream + logger: Logger + timeSegments: TimeSegment[] + onComplete: (result: OpenAIStreamingToolLoopComplete) => void +} + +interface OpenAIResponsesTurn { + response: OpenAI.Responses.Response + text: string + toolCalls: ResponsesToolCall[] +} + +interface OpenAIToolExecutionResult { + toolCall: ResponsesToolCall + toolName: string + toolParams: Record + result: { + success: boolean + output?: Record + error?: string + } + startTime: number + endTime: number + duration: number +} + +/** + * Streams one OpenAI Responses turn and returns its assembled terminal response. + */ +async function streamResponsesTurn( + response: Response, + controller: ReadableStreamDefaultController, + openTools: Map, + abortSignal?: AbortSignal +): Promise { + let terminalResponse: OpenAI.Responses.Response | undefined + let streamedText = '' + let sawFunctionCall = false + + for await (const event of iterateResponsesStreamEvents(response, abortSignal)) { + if (isResponseFunctionCallEvent(event)) { + sawFunctionCall = true + } + if (event.type === 'error') { + throw new Error(event.message || 'OpenAI Responses stream error') + } + if (event.type === 'response.failed') { + throw new Error(event.response.error?.message || 'OpenAI Responses stream failed') + } + if (event.type === 'response.incomplete') { + const reason = event.response.incomplete_details?.reason ?? 'unknown' + if ( + !isMaxOutputTokensIncompleteResponse(event.response) || + sawFunctionCall || + openTools.size > 0 || + responseContainsFunctionCall(event.response) + ) { + throw new Error(`OpenAI Responses stream incomplete: ${reason}`) + } + terminalResponse = event.response + continue + } + + if (event.type === 'response.reasoning_summary_text.delta') { + if (event.delta) { + controller.enqueue({ type: 'thinking_delta', text: event.delta }) + } + continue + } + + if (event.type === 'response.output_text.delta') { + if (event.delta) { + streamedText += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'pending' }) + } + continue + } + if (event.type === 'response.refusal.delta') { + if (event.delta) { + streamedText += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'pending' }) + } + continue + } + + if (event.type === 'response.output_item.added' && event.item.type === 'function_call') { + const id = event.item.call_id + const name = event.item.name + if (!openTools.has(id)) { + openTools.set(id, name) + controller.enqueue({ type: 'tool_call_start', id, name }) + } + continue + } + + if (event.type === 'response.completed') { + terminalResponse = event.response + } + } + + if (!terminalResponse) { + throw new Error('OpenAI Responses stream ended without a terminal response') + } + + const toolCalls = extractResponseToolCalls(terminalResponse.output) + const text = streamedText || extractResponseText(terminalResponse.output) + + return { response: terminalResponse, text, toolCalls } +} + +/** + * Finalizes one tool execution and emits its terminal lifecycle event. + */ +function completeToolExecution( + controller: ReadableStreamDefaultController, + openTools: Map, + toolCall: ResponsesToolCall, + toolParams: Record, + result: OpenAIToolExecutionResult['result'], + startTime: number, + status: ToolCallEndStatus +): OpenAIToolExecutionResult { + const endTime = Date.now() + openTools.delete(toolCall.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolCall.id, + name: toolCall.name, + status, + }) + return { + toolCall, + toolName: toolCall.name, + toolParams, + result, + startTime, + endTime, + duration: endTime - startTime, + } +} + +/** + * Executes one assembled OpenAI function call. + */ +async function executeOpenAIToolCall(options: { + toolCall: ResponsesToolCall + request: ProviderRequest + controller: ReadableStreamDefaultController + openTools: Map + logger: Logger +}): Promise { + const { toolCall, request, controller, openTools, logger } = options + const startTime = Date.now() + let toolArgs: Record + + try { + toolArgs = parseToolArguments(toolCall.arguments, toolCall.name) + } catch (error) { + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, `Invalid tool arguments for ${toolCall.name}`), + }, + startTime, + 'error' + ) + } + + const tool = request.tools?.find((candidate) => candidate.id === toolCall.name) + if (!tool) { + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: `Tool not found: ${toolCall.name}`, + }, + startTime, + 'error' + ) + } + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolCall.name, executionParams, { + signal: request.abortSignal, + }) + return completeToolExecution( + controller, + openTools, + toolCall, + toolParams, + result, + startTime, + result.success ? 'success' : 'error' + ) + } catch (error) { + if (request.abortSignal?.aborted) { + completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution cancelled'), + }, + startTime, + 'cancelled' + ) + throw error + } + if (isAbortError(error)) { + completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution aborted unexpectedly'), + }, + startTime, + 'error' + ) + throw error + } + + logger.error('Error processing OpenAI tool call:', { + error, + toolName: toolCall.name, + }) + return completeToolExecution( + controller, + openTools, + toolCall, + {}, + { + success: false, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime, + 'error' + ) + } +} + +/** + * Multi-turn OpenAI Responses tool loop as an `agent-events-v1` object stream. + */ +export function createOpenAIResponsesStreamingToolLoopStream( + options: CreateOpenAIResponsesStreamingToolLoopOptions +): ReadableStream { + const { + providerId, + providerLabel, + request, + initialInput, + initialToolChoice, + createStream, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + const loopAbortController = new AbortController() + const abortFromRequest = () => loopAbortController.abort(request.abortSignal?.reason) + let consumerCancelled = false + + if (request.abortSignal?.aborted) { + abortFromRequest() + } else { + request.abortSignal?.addEventListener('abort', abortFromRequest, { once: true }) + } + + const loopRequest: ProviderRequest = { + ...request, + abortSignal: loopAbortController.signal, + } + + return new ReadableStream({ + start(controller) { + void (async () => { + const currentInput = [...initialInput] + const usedForcedTools = new Set() + const usage = createOpenAIUsageAccumulator() + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openTools = new Map() + let currentToolChoice = initialToolChoice + let content = '' + let iterationCount = 0 + let modelCalls = 0 + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const reportProgress = () => { + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens: buildOpenAIUsageTokens(usage), + cost: buildOpenAIUsageCost(request.model, usage, toolCost), + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: modelCalls, + }) + } + + try { + while (modelCalls <= MAX_TOOL_ITERATIONS) { + if (loopAbortController.signal.aborted) { + settleOpenTools(controller, openTools, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const modelStart = Date.now() + const finalSynthesis = iterationCount >= MAX_TOOL_ITERATIONS + const streamResponse = await createStream( + currentInput, + { + stream: true, + ...(finalSynthesis + ? { tools: undefined, tool_choice: 'none' } + : currentToolChoice !== undefined + ? { tool_choice: currentToolChoice } + : {}), + }, + loopAbortController.signal + ) + const turn = await streamResponsesTurn( + streamResponse, + controller, + openTools, + loopAbortController.signal + ) + const modelEnd = Date.now() + const modelDuration = modelEnd - modelStart + const turnUsage = parseResponsesUsage(turn.response.usage) + const reachedToolLimit = iterationCount >= MAX_TOOL_ITERATIONS + const toolsExecutable = turn.response.status === 'completed' && !reachedToolLimit + const executableTools = toolsExecutable ? turn.toolCalls : [] + + if (turn.toolCalls.length > 0 && !toolsExecutable) { + logger.warn('Skipping OpenAI tool execution', { + status: turn.response.status, + toolCount: turn.toolCalls.length, + reachedToolLimit, + }) + settleOpenTools(controller, openTools, 'error') + } + + const executableToolIds = new Set(executableTools.map((toolCall) => toolCall.id)) + for (const [id, name] of openTools) { + if (!executableToolIds.has(id)) { + openTools.delete(id) + controller.enqueue({ type: 'tool_call_end', id, name, status: 'error' }) + } + } + + for (const toolCall of executableTools) { + if (!openTools.has(toolCall.id)) { + openTools.set(toolCall.id, toolCall.name) + controller.enqueue({ + type: 'tool_call_start', + id: toolCall.id, + name: toolCall.name, + }) + } + } + + const turnKind = executableTools.length > 0 ? 'intermediate' : 'final' + content = turn.text + controller.enqueue({ type: 'turn_end', turn: turnKind }) + + modelTime += modelDuration + modelCalls++ + if (modelCalls === 1) { + firstResponseTime = modelDuration + } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: modelDuration, + }) + enrichLastModelSegmentFromOpenAIResponse( + timeSegments, + turn.response, + turn.text, + turn.toolCalls, + { model: request.model } + ) + + addOpenAIUsage(usage, turnUsage) + + if (executableTools.length === 0) { + break + } + + currentInput.push(...turn.response.output) + + if (typeof currentToolChoice === 'object') { + for (const toolCall of executableTools) { + if (forcedTools.includes(toolCall.name)) { + usedForcedTools.add(toolCall.name) + } + } + } + + const toolsStart = Date.now() + const orderedResults = await Promise.all( + executableTools.map((toolCall) => + executeOpenAIToolCall({ + toolCall, + request: loopRequest, + controller, + openTools, + logger, + }) + ) + ) + + for (const result of orderedResults) { + timeSegments.push({ + type: 'tool', + name: result.toolName, + startTime: result.startTime, + endTime: result.endTime, + duration: result.duration, + toolCallId: result.toolCall.id, + }) + + const resultContent = result.result.success + ? (result.result.output ?? null) + : { + error: true, + message: result.result.error || 'Tool execution failed', + tool: result.toolName, + } + + if (result.result.success && isRecordLike(result.result.output)) { + toolResults.push(result.result.output) + } + + toolCalls.push({ + name: result.toolName, + arguments: result.toolParams, + startTime: new Date(result.startTime).toISOString(), + endTime: new Date(result.endTime).toISOString(), + duration: result.duration, + result: resultContent, + success: result.result.success, + }) + + currentInput.push({ + type: 'function_call_output', + call_id: result.toolCall.id, + output: JSON.stringify(resultContent), + }) + } + + toolsTime += Date.now() - toolsStart + + if (typeof currentToolChoice === 'object') { + const remaining = forcedTools.filter((toolName) => !usedForcedTools.has(toolName)) + currentToolChoice = + remaining.length > 0 ? { type: 'function', name: remaining[0] } : 'auto' + if (remaining.length === 0) { + logger.info('All forced tools have been used, switching to auto tool_choice') + } else { + logger.info(`Forcing next tool: ${remaining[0]}`) + } + } + + iterationCount++ + } + + reportProgress() + controller.close() + } catch (error) { + reportProgress() + terminateToolLoop({ + controller, + openTools, + aborted: loopAbortController.signal.aborted, + consumerCancelled, + error, + onUnexpectedError: (cause) => + logger.error(`Error in ${providerLabel} streaming tool loop`, { + providerId, + error: cause, + }), + }) + } finally { + request.abortSignal?.removeEventListener('abort', abortFromRequest) + } + })().catch((error) => { + // `start` cannot be async (the loop must not block the first pull), so + // a throw escaping the IIFE would surface as an unhandled rejection. + logger.error(`Unhandled failure in ${providerLabel} streaming tool loop`, { + providerId, + error: toError(error).message, + }) + }) + }, + cancel(reason) { + consumerCancelled = true + loopAbortController.abort(reason) + request.abortSignal?.removeEventListener('abort', abortFromRequest) + }, + }) +} diff --git a/apps/sim/providers/openai/trace.ts b/apps/sim/providers/openai/trace.ts new file mode 100644 index 00000000000..f0147d461d8 --- /dev/null +++ b/apps/sim/providers/openai/trace.ts @@ -0,0 +1,82 @@ +import type OpenAI from 'openai' +import type { IterationToolCall } from '@/executor/types' +import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' +import { + extractResponseReasoning, + parseResponsesUsage, + type ResponsesToolCall, + toOpenAIModelUsage, +} from '@/providers/openai/utils' +import { enrichLastModelSegment, parseToolCallArguments } from '@/providers/trace-enrichment' +import type { TimeSegment } from '@/providers/types' + +/** + * Maps a Responses API terminal response to Sim's conventional finish reason. + */ +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 latest model segment from a terminal Responses API response. + */ +export 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((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + arguments: parseToolCallArguments(toolCall.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 = priceModelUsage(extras.model, toOpenAIModelUsage(usage), LIST_PRICE_POLICY) + 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.cacheWriteTokens > 0 && { cacheWrite: usage.cacheWriteTokens }), + ...(usage.reasoningTokens > 0 && { reasoning: usage.reasoningTokens }), + } + : undefined, + cost, + provider: 'openai', + ttft: extras?.ttft, + errorType: extras?.errorType, + errorMessage: extras?.errorMessage, + }) +} diff --git a/apps/sim/providers/openai/usage.test.ts b/apps/sim/providers/openai/usage.test.ts new file mode 100644 index 00000000000..6d546a7db5f --- /dev/null +++ b/apps/sim/providers/openai/usage.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + addOpenAIUsage, + buildOpenAIUsageCost, + buildOpenAIUsageTokens, + createOpenAIUsageAccumulator, +} from '@/providers/openai/usage' +import type { ResponsesUsageTokens } from '@/providers/openai/utils' +import { calculateCost } from '@/providers/utils' + +/** input $2.50/M, cachedInput $1.25/M, output $10.00/M. */ +const MODEL = 'gpt-4o' +/** input $2.50/M, cachedInput $0.25/M, output $15.00/M — bills cache writes. */ +const CACHE_WRITE_MODEL = 'gpt-5.6-terra' + +/** + * Builds a Responses usage payload. `promptTokens` is inclusive of cached and + * written tokens, matching what {@link parseResponsesUsage} emits. + */ +function responsesUsage(partial: Partial): ResponsesUsageTokens { + const promptTokens = partial.promptTokens ?? 0 + const completionTokens = partial.completionTokens ?? 0 + return { + promptTokens, + completionTokens, + totalTokens: partial.totalTokens ?? promptTokens + completionTokens, + cachedTokens: partial.cachedTokens ?? 0, + cacheWriteTokens: partial.cacheWriteTokens ?? 0, + reasoningTokens: partial.reasoningTokens ?? 0, + } +} + +describe('OpenAI usage aggregation', () => { + it('matches plain list pricing when nothing was cached', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 12_345, completionTokens: 6_789 })) + + const uncached = calculateCost(MODEL, 12_345, 6_789) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 12_345, + output: 6_789, + total: 19_134, + cacheRead: 0, + cacheWrite: 0, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: uncached.input, + output: uncached.output, + total: uncached.total, + }) + }) + + it('bills cached tokens at the cached rate instead of the full input rate', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 1_000_000, cachedTokens: 600_000, completionTokens: 0 }) + ) + + /** 400k uncached at $2.50/M plus 600k cached at $1.25/M. */ + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: 1.75, + output: 0, + total: 1.75, + }) + expect(calculateCost(MODEL, 1_000_000, 0).input).toBe(2.5) + }) + + it('reports cache reads separately while keeping the prompt total intact', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 1_000, cachedTokens: 800, completionTokens: 100 }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 200, + output: 100, + total: 1_100, + cacheRead: 800, + cacheWrite: 0, + }) + }) + + it('bills GPT-5.6 cache writes at 1.25x the uncached input rate', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + completionTokens: 0, + }) + ) + + /** 1M written at $2.50/M x 1.25. */ + expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ + input: 3.125, + output: 0, + total: 3.125, + }) + }) + + it('aggregates uncached, cached, written, and output tokens in one turn', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000_000, + cachedTokens: 600_000, + cacheWriteTokens: 200_000, + completionTokens: 100_000, + }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 200_000, + output: 100_000, + total: 1_100_000, + cacheRead: 600_000, + cacheWrite: 200_000, + }) + /** 0.5 uncached + 0.15 cached + 0.625 written input, 1.5 output. */ + expect(buildOpenAIUsageCost(CACHE_WRITE_MODEL, usage)).toMatchObject({ + input: 1.275, + output: 1.5, + total: 2.775, + }) + }) + + it('accumulates each tool-loop turn exactly once', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000, completionTokens: 100 })) + addOpenAIUsage( + usage, + responsesUsage({ promptTokens: 2_000, cachedTokens: 1_500, completionTokens: 200 }) + ) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 1_500, + output: 300, + total: 3_300, + cacheRead: 1_500, + cacheWrite: 0, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).toMatchObject({ + input: 0.005625, + output: 0.003, + total: 0.008625, + }) + }) + + it('ignores turns that reported no usage', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000, completionTokens: 100 })) + addOpenAIUsage(usage, undefined) + + expect(buildOpenAIUsageTokens(usage)).toEqual({ + input: 1_000, + output: 100, + total: 1_100, + cacheRead: 0, + cacheWrite: 0, + }) + }) + + it('adds tool cost to the total and only reports the field when charged', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage(usage, responsesUsage({ promptTokens: 1_000_000, completionTokens: 0 })) + + expect(buildOpenAIUsageCost(MODEL, usage, 0.25)).toMatchObject({ + input: 2.5, + total: 2.75, + toolCost: 0.25, + }) + expect(buildOpenAIUsageCost(MODEL, usage)).not.toHaveProperty('toolCost') + }) + + it('does not charge for cache tokens a vendor payload over-reported', () => { + const usage = createOpenAIUsageAccumulator() + addOpenAIUsage( + usage, + responsesUsage({ + promptTokens: 1_000, + cachedTokens: 900, + cacheWriteTokens: 400, + completionTokens: 0, + }) + ) + + expect(buildOpenAIUsageTokens(usage)).toMatchObject({ + input: 0, + cacheRead: 900, + cacheWrite: 100, + }) + }) +}) diff --git a/apps/sim/providers/openai/usage.ts b/apps/sim/providers/openai/usage.ts new file mode 100644 index 00000000000..3d9cdb362e7 --- /dev/null +++ b/apps/sim/providers/openai/usage.ts @@ -0,0 +1,128 @@ +import type { BlockTokens } from '@/executor/types' +import { LIST_PRICE_POLICY, type ModelUsage, priceModelUsage } from '@/providers/cost-policy' +import { + OPENAI_CACHE_WRITE_MULTIPLIER, + type ResponsesUsageTokens, + splitOpenAIUsage, +} from '@/providers/openai/utils' +import type { ModelPricing } from '@/providers/types' + +export interface OpenAIUsageAccumulator { + /** + * Tokens billed at the base input rate. EXCLUDES cache reads and writes, + * which OpenAI reports as subsets of `input_tokens` and which are billed at + * their own rates. + */ + input: number + output: number + /** Every token the request consumed, cache reads and writes included. */ + total: number + cacheRead: number + cacheWrite: number +} + +interface OpenAIUsageCost { + input: number + output: number + total: number + toolCost?: number + pricing: ModelPricing +} + +function roundedCost(value: number): number { + return Number.parseFloat(value.toFixed(8)) +} + +/** + * Creates an empty accumulator for one OpenAI provider request. + */ +export function createOpenAIUsageAccumulator(): OpenAIUsageAccumulator { + return { + input: 0, + output: 0, + total: 0, + cacheRead: 0, + cacheWrite: 0, + } +} + +/** + * Adds one Responses API turn's usage without counting cache tokens as + * uncached input. + * + * Normalization goes through {@link splitOpenAIUsage} so that subtracting the + * cache buckets out of the prompt total — and clamping a vendor payload that + * reports more cache tokens than it processed — stays in one place. + */ +export function addOpenAIUsage( + accumulator: OpenAIUsageAccumulator, + usage: ResponsesUsageTokens | undefined +): void { + if (!usage) return + + const split = splitOpenAIUsage(usage) + + accumulator.input += split.input + accumulator.output += split.output + accumulator.cacheRead += split.cacheRead + accumulator.cacheWrite += split.cacheWrite + accumulator.total += usage.totalTokens +} + +/** + * Builds the block token shape. `total` is OpenAI's own reported total, which + * already counts cache reads and writes alongside the uncached remainder. + */ +export function buildOpenAIUsageTokens( + accumulator: OpenAIUsageAccumulator +): Required> { + return { + input: accumulator.input, + output: accumulator.output, + total: accumulator.total, + cacheRead: accumulator.cacheRead, + cacheWrite: accumulator.cacheWrite, + } +} + +/** + * Builds the normalized usage for one OpenAI request. + * + * `input` is already the uncached remainder because {@link addOpenAIUsage} + * subtracted the cache buckets per turn — unlike Anthropic, whose + * `input_tokens` arrives exclusive of them. + */ +export function buildOpenAIModelUsage(accumulator: OpenAIUsageAccumulator): ModelUsage { + return { + input: accumulator.input, + output: accumulator.output, + cacheRead: accumulator.cacheRead, + cacheWrites: [ + { tokens: accumulator.cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }, + ], + } +} + +/** + * Prices one OpenAI request, cache reads and writes included, through the + * shared pricing function. + * + * Always at list price. Billability and the margin are applied once, centrally, + * by `executeProviderRequest` — a provider applying them here would double-count + * the multiplier. + */ +export function buildOpenAIUsageCost( + model: string, + accumulator: OpenAIUsageAccumulator, + toolCost = 0 +): OpenAIUsageCost { + const cost = priceModelUsage(model, buildOpenAIModelUsage(accumulator), LIST_PRICE_POLICY) + + return { + input: cost.input, + output: cost.output, + total: roundedCost(cost.total + toolCost), + ...(toolCost > 0 ? { toolCost } : {}), + pricing: cost.pricing, + } +} diff --git a/apps/sim/providers/openai/utils.stream.test.ts b/apps/sim/providers/openai/utils.stream.test.ts index ed8b74899a8..bed841d345a 100644 --- a/apps/sim/providers/openai/utils.stream.test.ts +++ b/apps/sim/providers/openai/utils.stream.test.ts @@ -68,8 +68,140 @@ describe('createReadableStreamFromResponses', () => { event: 'response.output_text.delta', data: { type: 'response.output_text.delta', delta: 'Hi' }, }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + }, ]) const events = await collectEvents(createReadableStreamFromResponses(response)) expect(events).toEqual([{ type: 'text_delta', text: 'Hi', turn: 'final' }]) }) + + it('streams refusal text as the model answer', async () => { + const response = sseResponse([ + { + event: 'response.refusal.delta', + data: { type: 'response.refusal.delta', delta: "I can't help with that." }, + }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 1, output_tokens: 5 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response)) + expect(events).toEqual([{ type: 'text_delta', text: "I can't help with that.", turn: 'final' }]) + }) + + it('finalizes truncated text when max_output_tokens is the only incomplete condition', async () => { + const onComplete = vi.fn() + const response = sseResponse([ + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Truncated answer' }, + }, + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response, onComplete)) + + expect(events).toEqual([{ type: 'text_delta', text: 'Truncated answer', turn: 'final' }]) + expect(onComplete).toHaveBeenCalledWith( + 'Truncated answer', + { + promptTokens: 3, + completionTokens: 5, + totalTokens: 8, + cachedTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }, + undefined + ) + }) + + it('rejects a max_output_tokens response with a partial function call', async () => { + const response = sseResponse([ + { + event: 'response.output_item.added', + data: { + type: 'response.output_item.added', + item: { + id: 'fc_partial', + type: 'function_call', + call_id: 'call_partial', + name: 'lookup', + arguments: '', + status: 'in_progress', + }, + }, + }, + { + event: 'response.function_call_arguments.delta', + data: { + type: 'response.function_call_arguments.delta', + item_id: 'fc_partial', + output_index: 0, + delta: '{"query":', + }, + }, + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + }, + }, + }, + ]) + + await expect(collectEvents(createReadableStreamFromResponses(response))).rejects.toThrow( + 'OpenAI Responses stream incomplete: max_output_tokens' + ) + }) + + it('continues rejecting non-token-cap incomplete responses', async () => { + const response = sseResponse([ + { + event: 'response.incomplete', + data: { + type: 'response.incomplete', + response: { + status: 'incomplete', + incomplete_details: { reason: 'content_filter' }, + output: [], + }, + }, + }, + ]) + + await expect(collectEvents(createReadableStreamFromResponses(response))).rejects.toThrow( + 'OpenAI Responses stream incomplete: content_filter' + ) + }) }) diff --git a/apps/sim/providers/openai/utils.test.ts b/apps/sim/providers/openai/utils.test.ts index ba943f3f4aa..e8c97793b32 100644 --- a/apps/sim/providers/openai/utils.test.ts +++ b/apps/sim/providers/openai/utils.test.ts @@ -1,8 +1,82 @@ /** * @vitest-environment node */ +import type OpenAI from 'openai' import { describe, expect, it } from 'vitest' -import { buildResponsesInputFromMessages } from '@/providers/openai/utils' +import { + buildResponsesInputFromMessages, + parseResponsesUsage, + toOpenAIModelUsage, +} from '@/providers/openai/utils' + +describe('parseResponsesUsage', () => { + it('reads cache writes, which GPT-5.6+ bills at a premium', () => { + const usage = parseResponsesUsage({ + input_tokens: 1000, + output_tokens: 100, + total_tokens: 1100, + input_tokens_details: { cached_tokens: 600, cache_write_tokens: 200 }, + output_tokens_details: { reasoning_tokens: 0 }, + } as OpenAI.Responses.ResponseUsage) + + expect(usage).toMatchObject({ promptTokens: 1000, cachedTokens: 600, cacheWriteTokens: 200 }) + }) + + it('reports zero cache writes on model families that do not charge for them', () => { + const usage = parseResponsesUsage({ + input_tokens: 1000, + output_tokens: 100, + total_tokens: 1100, + input_tokens_details: { cached_tokens: 600 }, + output_tokens_details: { reasoning_tokens: 0 }, + } as OpenAI.Responses.ResponseUsage) + + expect(usage?.cacheWriteTokens).toBe(0) + }) +}) + +describe('toOpenAIModelUsage', () => { + /** + * OpenAI reports cached and written tokens as subsets of `input_tokens`; + * double-counting them would over-bill every cached request. + */ + it('subtracts cache buckets out of the prompt total', () => { + const usage = toOpenAIModelUsage({ + promptTokens: 1000, + completionTokens: 100, + totalTokens: 1100, + cachedTokens: 600, + cacheWriteTokens: 200, + reasoningTokens: 0, + }) + + expect(usage).toEqual({ + input: 200, + output: 100, + cacheRead: 600, + cacheWrites: [{ tokens: 200, inputRateMultiplier: 1.25 }], + }) + }) + + /** + * OpenAI has shipped payloads where reads plus writes exceeded the prompt + * total. Left unclamped that bills more input than the request contained. + */ + it('never bills more cache tokens than the request reported', () => { + const usage = toOpenAIModelUsage({ + promptTokens: 4583, + completionTokens: 15, + totalTokens: 4598, + cachedTokens: 3945, + cacheWriteTokens: 4580, + reasoningTokens: 0, + }) + + expect(usage.input).toBe(0) + expect(usage.cacheRead).toBe(3945) + expect(usage.cacheWrites?.[0].tokens).toBe(4583 - 3945) + }) +}) describe('buildResponsesInputFromMessages', () => { it('should convert user message files to Responses multipart content', () => { diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index 2aaf3cd0ad9..fa6e295f071 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -1,41 +1,141 @@ -import { createLogger } from '@sim/logger' import type OpenAI from 'openai' +import { Stream } from 'openai/streaming' import { buildOpenAIMessageContent } from '@/providers/attachments' +import type { ModelUsage } from '@/providers/cost-policy' import type { AgentStreamEvent } from '@/providers/stream-events' import type { Message } from '@/providers/types' -const logger = createLogger('ResponsesUtils') - export interface ResponsesUsageTokens { + /** Total input tokens. INCLUDES {@link cachedTokens}, per the OpenAI usage schema. */ promptTokens: number completionTokens: number totalTokens: number + /** Input tokens served from cache — a subset of {@link promptTokens}. */ cachedTokens: number + /** Input tokens written to cache. Billed at 1.25x on GPT-5.6+, free before it. */ + cacheWriteTokens: number reasoningTokens: number } +/** GPT-5.6 and later bill cache writes at 1.25x the uncached input rate. */ +export const OPENAI_CACHE_WRITE_MULTIPLIER = 1.25 + +/** + * One OpenAI response's tokens split into the buckets that price differently. + * `input` is the uncached remainder, so `input + cacheRead + cacheWrite` is the + * prompt total OpenAI reported. + */ +export interface OpenAITokenSplit { + input: number + output: number + cacheRead: number + cacheWrite: number +} + +/** + * Splits a cache-inclusive OpenAI prompt total into its billing buckets. + * + * `cached_tokens` and `cache_write_tokens` are subsets of `input_tokens`, so the + * uncached remainder is the subtraction — the opposite of Anthropic, whose + * `input_tokens` already excludes cache tokens. + * + * Both buckets are clamped to what the request actually contained. OpenAI has + * shipped payloads where reads plus writes summed past the prompt total, so + * without this a vendor reporting bug becomes an overcharge. + */ +export function splitOpenAIUsage(usage: ResponsesUsageTokens): OpenAITokenSplit { + const promptTokens = Math.max(0, usage.promptTokens) + const cacheRead = Math.min(Math.max(0, usage.cachedTokens), promptTokens) + const cacheWrite = Math.min(Math.max(0, usage.cacheWriteTokens), promptTokens - cacheRead) + + return { + input: promptTokens - cacheRead - cacheWrite, + output: usage.completionTokens, + cacheRead, + cacheWrite, + } +} + +/** Adapts a {@link splitOpenAIUsage} result to the shared pricing shape. */ +export function toOpenAIModelUsage(usage: ResponsesUsageTokens): ModelUsage { + const { input, output, cacheRead, cacheWrite } = splitOpenAIUsage(usage) + + return { + input, + output, + cacheRead, + cacheWrites: [{ tokens: cacheWrite, inputRateMultiplier: OPENAI_CACHE_WRITE_MULTIPLIER }], + } +} + 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 +export type ResponsesStreamEvent = OpenAI.Responses.ResponseStreamEvent + +export type ResponsesInputItem = OpenAI.Responses.ResponseInputItem + +/** + * Identifies the one incomplete Responses status that still contains a valid + * truncated answer: the configured output-token cap was reached. + */ +export function isMaxOutputTokensIncompleteResponse(response: OpenAI.Responses.Response): boolean { + return ( + response.status === 'incomplete' && response.incomplete_details?.reason === 'max_output_tokens' + ) +} + +/** + * Checks the terminal Responses output for a function call, including one + * whose arguments or status remain incomplete. + */ +export function responseContainsFunctionCall(response: OpenAI.Responses.Response): boolean { + return response.output.some((item) => item.type === 'function_call') +} + +/** + * Detects documented Responses stream events that prove function-call + * generation started, even when the terminal output omits the partial item. + */ +export function isResponseFunctionCallEvent(event: ResponsesStreamEvent): boolean { + return ( + (event.type === 'response.output_item.added' && event.item.type === 'function_call') || + event.type === 'response.function_call_arguments.delta' || + event.type === 'response.function_call_arguments.done' + ) +} + +/** + * Parses a Responses API SSE body with the official OpenAI stream decoder. + */ +export async function* iterateResponsesStreamEvents( + response: Response, + abortSignal?: AbortSignal +): AsyncGenerator { + const parserController = new AbortController() + const abortParser = () => parserController.abort(abortSignal?.reason) + + if (abortSignal?.aborted) { + abortParser() + } else { + abortSignal?.addEventListener('abort', abortParser, { once: true }) + } + + try { + const stream = Stream.fromSSEResponse(response, parserController) + for await (const event of stream) { + yield event } - | { - type: 'function_call_output' - call_id: string - output: string + } finally { + abortSignal?.removeEventListener('abort', abortParser) + if (!parserController.signal.aborted) { + parserController.abort() } + } +} export interface ResponsesToolDefinition { type: 'function' @@ -44,6 +144,8 @@ export interface ResponsesToolDefinition { parameters?: Record } +export type ResponsesToolChoice = 'auto' | 'none' | { type: 'function'; name: string } + /** * Converts chat-style messages into Responses API input items. */ @@ -136,7 +238,7 @@ export function toResponsesToolChoice( | { type: 'tool'; name: string } | { type: 'any'; any: { model: string; name: string } } | undefined -): 'auto' | 'none' | { type: 'function'; name: string } | undefined { +): ResponsesToolChoice | undefined { if (!toolChoice) { return undefined } @@ -176,17 +278,10 @@ function extractTextFromMessageItem(item: unknown): string { continue } - if ((part.type === 'output_text' || part.type === 'text') && typeof part.text === 'string') { + if (part.type === 'output_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)) - } + } else if (part.type === 'refusal' && typeof part.refusal === 'string') { + textParts.push(part.refusal) } } @@ -228,13 +323,8 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput const parts: string[] = [] for (const item of output) { if (!item || item.type !== 'reasoning') continue - // double-cast-allowed: the Responses SDK types summary entries as non-null, but the wire can carry null entries/text on partial reasoning items; widen to read defensively - 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) + for (const entry of item.summary) { + if (entry.text.length > 0) parts.push(entry.text) } } return parts.join('\n\n') @@ -246,75 +336,7 @@ export function extractResponseReasoning(output: OpenAI.Responses.ResponseOutput 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 - const fn = tc.function as Record | 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 + return Array.isArray(output) ? output : [] } /** @@ -336,13 +358,7 @@ export function extractResponseToolCalls( 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) { + if (!fc.call_id || !fc.name) { continue } @@ -350,33 +366,10 @@ export function extractResponseToolCalls( typeof fc.arguments === 'string' ? fc.arguments : JSON.stringify(fc.arguments ?? {}) toolCalls.push({ - id: callId, - name, + id: fc.call_id, + name: fc.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 - const fn = tc.function as Record | 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, - }) - } } } @@ -398,7 +391,12 @@ export function parseResponsesUsage( const inputTokens = usage.input_tokens ?? 0 const outputTokens = usage.output_tokens ?? 0 - const cachedTokens = usage.input_tokens_details?.cached_tokens ?? 0 + const details = usage.input_tokens_details as + | { cached_tokens?: number | null; cache_write_tokens?: number | null } + | undefined + const cachedTokens = details?.cached_tokens ?? 0 + // Added for GPT-5.6; absent (and free) on earlier model families. + const cacheWriteTokens = details?.cache_write_tokens ?? 0 const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0 const completionTokens = Math.max(outputTokens, reasoningTokens) const totalTokens = inputTokens + completionTokens @@ -408,6 +406,7 @@ export function parseResponsesUsage( completionTokens, totalTokens, cachedTokens, + cacheWriteTokens, reasoningTokens, } } @@ -424,141 +423,86 @@ export function createReadableStreamFromResponses( response: Response, onComplete?: (content: string, usage?: ResponsesUsageTokens, thinking?: string) => void ): ReadableStream { - let fullContent = '' - let fullThinking = '' - let finalUsage: ResponsesUsageTokens | undefined - let activeEventType: string | undefined + const streamAbortController = new AbortController() return new ReadableStream({ - 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 + start(controller) { + void (async () => { + let fullContent = '' + let fullThinking = '' + let finalUsage: ResponsesUsageTokens | undefined + let completed = false + let sawFunctionCall = false + + try { + for await (const event of iterateResponsesStreamEvents( + response, + streamAbortController.signal + )) { + if (isResponseFunctionCallEvent(event)) { + sawFunctionCall = true } - - if (trimmed.startsWith('event:')) { - activeEventType = trimmed.slice(6).trim() - continue + if (event.type === 'error') { + throw new Error(event.message || 'OpenAI Responses stream error') } - - if (!trimmed.startsWith('data:')) { - continue + if (event.type === 'response.failed') { + throw new Error(event.response.error?.message || 'OpenAI Responses stream failed') } - - const data = trimmed.slice(5).trim() - if (data === '[DONE]') { + if (event.type === 'response.incomplete') { + const reason = event.response.incomplete_details?.reason ?? 'unknown' + if ( + !isMaxOutputTokensIncompleteResponse(event.response) || + sawFunctionCall || + responseContainsFunctionCall(event.response) + ) { + throw new Error(`OpenAI Responses stream incomplete: ${reason}`) + } + finalUsage = parseResponsesUsage(event.response.usage) + completed = true continue } - - let event: Record - try { - event = JSON.parse(data) - } catch (error) { - logger.debug('Skipping non-JSON response stream chunk', { - data: data.slice(0, 200), - error, - }) + if (event.type === 'response.reasoning_summary_text.delta') { + if (event.delta) { + fullThinking += event.delta + controller.enqueue({ type: 'thinking_delta', text: event.delta }) + } continue } - - const eventType = event?.type ?? activeEventType - - if ( - eventType === 'response.error' || - eventType === 'error' || - eventType === 'response.failed' - ) { - const errorObj = event.error as Record | undefined - const message = (errorObj?.message as string) || 'Responses API stream error' - controller.error(new Error(message)) - return - } - - // Reasoning *summaries* only (`response.reasoning_summary_text.delta` - // per the Responses streaming reference) — never raw reasoning - // (`response.reasoning_text.delta`) and never encrypted_content. - if (eventType === 'response.reasoning_summary_text.delta') { - let deltaText = '' - const delta = event.delta as string | Record | undefined - if (typeof delta === 'string') { - deltaText = delta - } else if (delta && typeof delta.text === 'string') { - deltaText = delta.text - } else if (typeof event.text === 'string') { - deltaText = event.text - } - if (deltaText.length > 0) { - fullThinking += deltaText - controller.enqueue({ type: 'thinking_delta', text: deltaText }) + if (event.type === 'response.output_text.delta') { + if (event.delta) { + fullContent += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'final' }) } continue } - - if ( - eventType === 'response.output_text.delta' || - eventType === 'response.output_json.delta' - ) { - let deltaText = '' - const delta = event.delta as string | Record | 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({ type: 'text_delta', text: deltaText, turn: 'final' }) + if (event.type === 'response.refusal.delta') { + if (event.delta) { + fullContent += event.delta + controller.enqueue({ type: 'text_delta', text: event.delta, turn: 'final' }) } + continue } - - if (eventType === 'response.completed') { - const responseObj = event.response as Record | undefined - const usageData = (responseObj?.usage ?? event.usage) as - | OpenAI.Responses.ResponseUsage - | undefined - finalUsage = parseResponsesUsage(usageData) + if (event.type === 'response.completed') { + finalUsage = parseResponsesUsage(event.response.usage) + completed = true } } - } - if (onComplete) { - onComplete(fullContent, finalUsage, fullThinking || undefined) - } + if (!completed) { + throw new Error('OpenAI Responses stream ended without a completed response') + } - controller.close() - } catch (error) { - controller.error(error) - } finally { - reader.releaseLock() - } + onComplete?.(fullContent, finalUsage, fullThinking || undefined) + controller.close() + } catch (error) { + if (!streamAbortController.signal.aborted) { + controller.error(error) + } + } + })() + }, + cancel(reason) { + streamAbortController.abort(reason) }, }) } diff --git a/apps/sim/providers/openrouter/index.test.ts b/apps/sim/providers/openrouter/index.test.ts index 8c26f611b8c..c611a5ee7f8 100644 --- a/apps/sim/providers/openrouter/index.test.ts +++ b/apps/sim/providers/openrouter/index.test.ts @@ -70,7 +70,9 @@ vi.mock('@/providers/utils', () => ({ generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), })) +import type { StreamingExecution } from '@/executor/types' import { openRouterProvider } from '@/providers/openrouter/index' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types' interface Usage { @@ -89,12 +91,25 @@ function textResponse( } } -function toolCallResponse(name: string, args: Record, id = 'call_1') { +function toolCallResponse( + name: string, + args: Record, + id = 'call_1', + assistant: { + content?: string | null + reasoning?: string + reasoning_details?: OpenRouterReasoningDetail[] + } = {} +) { return { choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning !== undefined ? { reasoning: assistant.reasoning } : {}), + ...(assistant.reasoning_details !== undefined + ? { reasoning_details: assistant.reasoning_details } + : {}), tool_calls: [ { id, type: 'function', function: { name, arguments: JSON.stringify(args) } }, ], @@ -129,6 +144,9 @@ describe('openRouterProvider.executeRequest', () => { mockCreate.mockReset() mockExecuteTool.mockReset() mockSupportsNative.mockResolvedValue(false) + mockCreateStream.mockReturnValue( + new ReadableStream({ start: (controller) => controller.close() }) + ) }) it('requires an API key', async () => { @@ -233,6 +251,85 @@ describe('openRouterProvider.executeRequest', () => { }) }) + it('replays OpenRouter reasoning_details unchanged with assistant content', async () => { + const reasoningDetails: OpenRouterReasoningDetail[] = [ + { + type: 'reasoning.summary', + summary: 'Need current weather.', + id: 'reasoning-1', + format: 'anthropic-claude-v1', + index: 0, + }, + { + type: 'reasoning.encrypted', + data: 'opaque-data', + id: 'reasoning-2', + format: 'anthropic-claude-v1', + index: 1, + }, + { + type: 'reasoning.text', + text: 'Call the weather tool.', + signature: null, + id: 'reasoning-3', + format: 'anthropic-claude-v1', + index: 2, + }, + ] + mockCreate + .mockResolvedValueOnce( + toolCallResponse('get_weather', { city: 'SF' }, 'call_1', { + content: 'I will check.', + reasoning_details: reasoningDetails, + }) + ) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will check.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"SF"}' }, + }, + ], + reasoning_details: reasoningDetails, + }) + }) + + it('does not add OpenRouter reasoning fields when the provider omitted them', async () => { + mockCreate + .mockResolvedValueOnce( + toolCallResponse('get_weather', { city: 'SF' }, 'call_1', { + content: 'I will check.', + }) + ) + .mockResolvedValueOnce(textResponse('done')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: { temp: 70 } }) + + await openRouterProvider.executeRequest({ + ...baseRequest, + tools: [tool('get_weather')], + }) + + const assistant = mockCreate.mock.calls[1][0].messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).not.toHaveProperty('reasoning') + expect(assistant).not.toHaveProperty('reasoning_details') + }) + it('applies native structured outputs (json_schema + require_parameters) when no tools are active', async () => { mockSupportsNative.mockResolvedValue(true) mockCreate.mockResolvedValueOnce(textResponse('{"x":1}')) @@ -329,8 +426,40 @@ describe('openRouterProvider.executeRequest', () => { expect(res).toHaveProperty('execution.output.model', 'anthropic/claude-3.5-sonnet') }) + it('streams the settled tool-loop answer without a duplicate provider request', 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, + stream: true, + tools: [tool('get_weather')], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(res.execution.output).toMatchObject({ + content: 'It is sunny', + tokens: { input: 28, output: 10, total: 38 }, + toolCalls: { count: 1 }, + }) + const reader = res.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'It is sunny', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + }) + it('stops the tool loop at MAX_TOOL_ITERATIONS', async () => { - mockCreate.mockResolvedValue(toolCallResponse('looping', {})) + mockCreate.mockImplementation((payload) => + payload.tool_choice === 'none' + ? textResponse('iteration limit answer') + : toolCallResponse('looping', {}) + ) mockExecuteTool.mockResolvedValue({ success: true, output: {} }) const res = (await openRouterProvider.executeRequest({ @@ -338,9 +467,11 @@ describe('openRouterProvider.executeRequest', () => { tools: [tool('looping')], })) as ProviderResponse - expect(mockCreate).toHaveBeenCalledTimes(11) + expect(mockCreate).toHaveBeenCalledTimes(12) expect(mockExecuteTool).toHaveBeenCalledTimes(10) expect(res.toolCalls?.length).toBe(10) + expect(res.content).toBe('iteration limit answer') + expect(mockCreate.mock.calls.at(-1)?.[0]).toMatchObject({ tool_choice: 'none' }) }) it('wraps SDK errors in a ProviderError', async () => { diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 72780553942..41146d1599c 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -1,17 +1,28 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' +import type { + ChatCompletionCreateParamsStreaming, + ChatCompletionMessage, +} 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 { + createOpenAICompatAssistantHistory, + type OpenAICompatAssistantHistoryMessage, +} from '@/providers/openai-compat/assistant-history' +import type { OpenRouterReasoningDetail } from '@/providers/openrouter/reasoning' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, supportsNativeStructuredOutputs, } from '@/providers/openrouter/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -34,6 +45,12 @@ import { executeTool } from '@/tools' const logger = createLogger('OpenRouterProvider') +type OpenRouterAssistantMessage = ChatCompletionMessage & { + reasoning?: string + reasoning_content?: string + reasoning_details?: OpenRouterReasoningDetail[] +} + /** * 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. @@ -265,10 +282,25 @@ export const openRouterProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -286,6 +318,9 @@ export const openRouterProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (OpenRouter):', { error: toError(error).message, @@ -308,26 +343,27 @@ export const openRouterProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message as + | OpenRouterAssistantMessage + | undefined + if (assistantMessage) { + const assistantHistory: OpenAICompatAssistantHistoryMessage & { + reasoning_details?: OpenRouterReasoningDetail[] + } = createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + if (Array.isArray(assistantMessage.reasoning_details)) { + assistantHistory.reasoning_details = assistantMessage.reasoning_details + } + currentMessages.push(assistantHistory) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -338,10 +374,12 @@ export const openRouterProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -419,92 +457,61 @@ export const openRouterProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'openrouter' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'openrouter', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - /** - * The regeneration exists purely to stream the settled answer as prose — - * streamed tool_calls are never executed on this path. - */ - const streamingParams: ChatCompletionCreateParamsStreaming & { provider?: any } = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromOpenAIStream(streamResponse, (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('OpenRouter final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - 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, - } - }), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'openrouter' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -560,6 +567,45 @@ export const openRouterProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -577,7 +623,7 @@ export const openRouterProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -600,6 +646,10 @@ export const openRouterProvider: ProviderConfig = { } logger.error('Error in OpenRouter request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/openrouter/reasoning.ts b/apps/sim/providers/openrouter/reasoning.ts new file mode 100644 index 00000000000..2ebaa05bc91 --- /dev/null +++ b/apps/sim/providers/openrouter/reasoning.ts @@ -0,0 +1,35 @@ +export type OpenRouterReasoningFormat = + | 'unknown' + | 'openai-responses-v1' + | 'azure-openai-responses-v1' + | 'xai-responses-v1' + | 'anthropic-claude-v1' + | 'google-gemini-v1' + +interface OpenRouterReasoningDetailBase { + format?: OpenRouterReasoningFormat + id?: string | null + index?: number +} + +export type OpenRouterReasoningDetail = + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.encrypted' + data: string + }) + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.summary' + summary: string + }) + | (OpenRouterReasoningDetailBase & { + type: 'reasoning.text' + signature?: string | null + text?: string | null + }) + +/** Returns the user-displayable text from a documented OpenRouter reasoning block. */ +export function getOpenRouterReasoningDetailText(detail: OpenRouterReasoningDetail): string { + if (detail.type === 'reasoning.summary') return detail.summary + if (detail.type === 'reasoning.text' && typeof detail.text === 'string') return detail.text + return '' +} diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 5b9e6e54e52..7e9cf12da79 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { StreamingExecution } from '@/executor/types' @@ -7,7 +8,9 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -127,6 +130,7 @@ export const sakanaProvider: ProviderConfig = { // 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 + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload } @@ -260,7 +264,7 @@ export const sakanaProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) // Every tool_call in the assistant message must be answered by a matching @@ -299,6 +303,9 @@ export const sakanaProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -318,7 +325,7 @@ export const sakanaProvider: ProviderConfig = { } }) - const executionResults = await Promise.allSettled(toolExecutionPromises) + const executionResults = await Promise.all(toolExecutionPromises) currentMessages.push({ role: 'assistant', @@ -333,11 +340,9 @@ export const sakanaProvider: ProviderConfig = { })), }) - for (const settledResult of executionResults) { - if (settledResult.status === 'rejected' || !settledResult.value) continue - + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -348,10 +353,12 @@ export const sakanaProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -454,109 +461,69 @@ export const sakanaProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { 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 - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + } + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.parallel_tool_calls = false + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await sakana.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromSakanaStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (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, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - 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, - } - } - ), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'sakana' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Sakana request:', { error }) + throw error } // 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) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred JSON schema response format after tool processing') const finalFormatStartTime = Date.now() @@ -602,6 +569,52 @@ export const sakanaProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -619,7 +632,7 @@ export const sakanaProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -633,6 +646,10 @@ export const sakanaProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/settled-tool-streams.test.ts b/apps/sim/providers/settled-tool-streams.test.ts new file mode 100644 index 00000000000..d7d1ea5c4c9 --- /dev/null +++ b/apps/sim/providers/settled-tool-streams.test.ts @@ -0,0 +1,572 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { basetenProvider } from '@/providers/baseten/index' +import { cerebrasProvider } from '@/providers/cerebras' +import { fireworksProvider } from '@/providers/fireworks/index' +import { kimiProvider } from '@/providers/kimi' +import { metaProvider } from '@/providers/meta' +import { nvidiaProvider } from '@/providers/nvidia' +import { openRouterProvider } from '@/providers/openrouter/index' +import { sakanaProvider } from '@/providers/sakana' +import { togetherProvider } from '@/providers/together/index' +import type { ProviderConfig, ProviderResponse, ProviderToolConfig } from '@/providers/types' +import { xAIProvider } from '@/providers/xai' +import { zaiProvider } from '@/providers/zai' + +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('@cerebras/cerebras_cloud_sdk', () => ({ + Cerebras: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 1 })) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/models', () => ({ + getProviderFileAttachment: vi + .fn() + .mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }), + INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024, + getModelCapabilities: vi.fn(), + getProviderModels: vi.fn((provider: string) => [`${provider}/test-model`]), + getProviderDefaultModel: vi.fn((provider: string) => `${provider}/test-model`), +})) + +vi.mock('@/providers/tool-schema-adapter', () => ({ + adaptOpenAIChatToolSchema: vi.fn((tool: ProviderToolConfig) => ({ + type: 'function', + function: { + name: tool.id, + description: tool.description, + parameters: tool.parameters, + }, + })), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 1, output: 2, total: 3 })), + enforceStrictSchema: vi.fn((schema) => schema), + generateSchemaInstructions: vi.fn(() => 'SCHEMA_INSTRUCTIONS'), + prepareToolExecution: vi.fn((_tool, args) => ({ + toolParams: args, + executionParams: args, + })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [{ type: 'function', function: { name: 'lookup' } }], + toolChoice: 'auto', + forcedTools: [], + hasFilteredTools: false, + })), + sumToolCosts: vi.fn(() => 4), + trackForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), +})) + +vi.mock('@/providers/baseten/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/fireworks/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/openrouter/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/together/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromOpenAIStream: vi.fn(() => createEmptyStream()), + supportsNativeStructuredOutputs: vi.fn(() => true), +})) +vi.mock('@/providers/cerebras/utils', () => ({ + createReadableStreamFromCerebrasStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/kimi/utils', () => ({ + createReadableStreamFromKimiStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/meta/utils', () => ({ + createReadableStreamFromMetaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/nvidia/utils', () => ({ + createReadableStreamFromNvidiaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/sakana/utils', () => ({ + createReadableStreamFromSakanaStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/zai/utils', () => ({ + createReadableStreamFromZaiStream: vi.fn(() => createEmptyStream()), +})) +vi.mock('@/providers/xai/utils', () => ({ + checkForForcedToolUsage: vi.fn(() => ({ + hasUsedForcedTool: false, + usedForcedTools: [], + })), + createReadableStreamFromXAIStream: vi.fn(() => createEmptyStream()), + createResponseFormatPayload: vi.fn(() => ({})), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) + +interface ToolCall { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +const TOOL: ProviderToolConfig = { + id: 'lookup', + name: 'lookup', + description: 'Looks up a value', + params: {}, + parameters: { type: 'object', properties: {}, required: [] }, +} + +const PROVIDERS = [ + { name: 'Cerebras', provider: cerebrasProvider, model: 'cerebras/test-model' }, + { name: 'Z.ai', provider: zaiProvider, model: 'zai/test-model' }, + { name: 'Sakana', provider: sakanaProvider, model: 'sakana/test-model' }, + { name: 'Kimi', provider: kimiProvider, model: 'kimi/test-model' }, + { name: 'Meta', provider: metaProvider, model: 'meta/test-model' }, + { name: 'NVIDIA', provider: nvidiaProvider, model: 'nvidia/test-model' }, + { name: 'xAI', provider: xAIProvider, model: 'xai/test-model' }, +] as const + +const REASONING_HISTORY_PROVIDERS = [ + { + name: 'Cerebras', + provider: cerebrasProvider, + model: 'cerebras/test-model', + field: 'reasoning', + }, + { + name: 'Kimi', + provider: kimiProvider, + model: 'kimi/test-model', + field: 'reasoning_content', + }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + field: 'reasoning_content', + }, + { + name: 'xAI', + provider: xAIProvider, + model: 'xai/test-model', + field: 'reasoning_content', + }, + { + name: 'Z.ai', + provider: zaiProvider, + model: 'zai/test-model', + field: 'reasoning_content', + }, +] as const + +const CAPPED_PROVIDERS = [ + { + name: 'Cerebras', + provider: cerebrasProvider, + model: 'cerebras/test-model', + disablesTools: 'none', + }, + { name: 'Z.ai', provider: zaiProvider, model: 'zai/test-model', disablesTools: 'omit' }, + { + name: 'Sakana', + provider: sakanaProvider, + model: 'sakana/test-model', + disablesTools: 'none', + }, + { name: 'Kimi', provider: kimiProvider, model: 'kimi/test-model', disablesTools: 'omit' }, + { name: 'Meta', provider: metaProvider, model: 'meta/test-model', disablesTools: 'omit' }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + disablesTools: 'none', + }, +] as const + +const STRUCTURED_OUTPUT_PROVIDERS = [ + { + name: 'Baseten', + provider: basetenProvider, + model: 'baseten/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Fireworks', + provider: fireworksProvider, + model: 'fireworks/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'OpenRouter', + provider: openRouterProvider, + model: 'openrouter/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Together', + provider: togetherProvider, + model: 'together/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'Meta', + provider: metaProvider, + model: 'meta/test-model', + responseFormatType: 'json_schema', + disablesTools: 'omit', + }, + { + name: 'NVIDIA', + provider: nvidiaProvider, + model: 'nvidia/test-model', + responseFormatType: 'json_schema', + disablesTools: 'none', + }, + { + name: 'Sakana', + provider: sakanaProvider, + model: 'sakana/test-model', + responseFormatType: 'json_schema', + disablesTools: 'none', + }, + { + name: 'Z.ai', + provider: zaiProvider, + model: 'zai/test-model', + responseFormatType: 'json_object', + disablesTools: 'omit', + }, +] as const + +function createEmptyStream(): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) +} + +function toolCall(id: string, argumentsJson = '{}'): ToolCall { + return { + id, + type: 'function', + function: { name: 'lookup', arguments: argumentsJson }, + } +} + +function response( + content: string | null, + toolCalls?: ToolCall[], + reasoning?: { reasoning?: string; reasoning_content?: string } +) { + return { + choices: [{ message: { content, tool_calls: toolCalls, ...reasoning } }], + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + } +} + +async function executeStreamingRequest( + provider: ProviderConfig, + model: string +): Promise { + return provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + stream: true, + }) +} + +async function executeStreamingStructuredRequest( + provider: ProviderConfig, + model: string +): Promise { + return provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + responseFormat: { + name: 'lookup_result', + schema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, + strict: true, + }, + stream: true, + }) +} + +async function readSettledEvents(result: ProviderResponse | StreamingExecution) { + if (!('stream' in result)) { + throw new Error('Expected a streaming execution') + } + + const events: unknown[] = [] + const reader = result.stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return { events, result } +} + +function expectModelIterations(result: StreamingExecution, expectedIterations: number) { + const timing = result.execution.output.providerTiming + expect(timing?.iterations).toBe(expectedIterations) + expect(timing?.timeSegments?.filter((segment) => segment.type === 'model')).toHaveLength( + expectedIterations + ) +} + +describe('settled provider tool streams', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreate.mockReset() + mockExecuteTool.mockReset() + mockExecuteTool.mockResolvedValue({ success: true, output: { value: 'found' } }) + }) + + it.each(PROVIDERS)( + '$name projects the existing final answer without another provider call', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('final answer')) + + const { events, result } = await readSettledEvents( + await executeStreamingRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockCreate.mock.calls.some(([payload]) => payload.stream === true)).toBe(false) + expect(result.streamFormat).toBe('agent-events-v1') + expect(events).toEqual([{ type: 'text_delta', text: 'final answer', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: 'final answer', + tokens: { input: 10, output: 6, total: 16 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + toolCalls: { count: 1 }, + }) + expectModelIterations(result, 2) + } + ) + + it.each(STRUCTURED_OUTPUT_PROVIDERS)( + '$name performs deferred structured extraction before projecting a settled stream', + async ({ provider, model, responseFormatType, disablesTools }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('intermediate answer')) + .mockResolvedValueOnce(response('{"value":"found"}')) + + const { events, result } = await readSettledEvents( + await executeStreamingStructuredRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(mockCreate.mock.calls[0][0].response_format).toBeUndefined() + const finalPayload = mockCreate.mock.calls[2][0] + expect(finalPayload.response_format).toMatchObject({ type: responseFormatType }) + if (disablesTools === 'none') { + expect(finalPayload.tool_choice).toBe('none') + } else { + expect(finalPayload.tools).toBeUndefined() + expect(finalPayload.tool_choice).toBeUndefined() + } + expect(events).toEqual([{ type: 'text_delta', text: '{"value":"found"}', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: '{"value":"found"}', + tokens: { input: 15, output: 9, total: 24 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + toolCalls: { count: 1 }, + }) + expectModelIterations(result, 3) + } + ) + + it.each(STRUCTURED_OUTPUT_PROVIDERS)( + '$name makes only one schema-bearing final call when the tool loop reaches its cap', + async ({ provider, model, responseFormatType }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response(null, [toolCall('call-2')])) + .mockResolvedValueOnce(response('{"value":"capped"}')) + + const { events, result } = await readSettledEvents( + await executeStreamingStructuredRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + expect(mockCreate.mock.calls[2][0].response_format).toMatchObject({ + type: responseFormatType, + }) + expect(events).toEqual([{ type: 'text_delta', text: '{"value":"capped"}', turn: 'final' }]) + expect(result.execution.output).toMatchObject({ + content: '{"value":"capped"}', + tokens: { input: 15, output: 9, total: 24 }, + cost: { input: 1, output: 2, toolCost: 4, total: 7 }, + }) + expectModelIterations(result, 3) + } + ) + + it.each(REASONING_HISTORY_PROVIDERS)( + '$name preserves the complete reasoning-bearing assistant tool turn', + async ({ provider, model, field }) => { + mockCreate + .mockResolvedValueOnce( + response('I need the lookup result.', [toolCall('call-1')], { + [field]: 'provider reasoning', + }) + ) + .mockResolvedValueOnce(response('final answer')) + + await provider.executeRequest({ + apiKey: 'test-key', + model, + messages: [{ role: 'user', content: 'Use the lookup tool' }], + tools: [TOOL], + }) + + const secondPayload = mockCreate.mock.calls[1][0] as { + messages: Array> + } + const assistant = secondPayload.messages.find((message) => message.role === 'assistant') + expect(assistant).toEqual({ + role: 'assistant', + content: 'I need the lookup result.', + tool_calls: [toolCall('call-1')], + [field]: 'provider reasoning', + }) + } + ) + + it.each(PROVIDERS)( + '$name rejects tool AbortError instead of replaying it as a result', + async ({ provider, model }) => { + mockCreate.mockResolvedValueOnce(response(null, [toolCall('call-1')])) + mockExecuteTool.mockRejectedValueOnce(new DOMException('cancelled', 'AbortError')) + + await expect(executeStreamingRequest(provider, model)).rejects.toMatchObject({ + name: 'AbortError', + }) + expect(mockCreate).toHaveBeenCalledTimes(1) + } + ) + + it.each(PROVIDERS)( + '$name does not execute malformed tool arguments', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1', '{"query":')])) + .mockResolvedValueOnce(response('recovered')) + + await executeStreamingRequest(provider, model) + + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockCreate).toHaveBeenCalledTimes(2) + } + ) + + it.each(PROVIDERS)( + '$name replays a successful false output as a valid tool result', + async ({ provider, model }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response('final answer')) + mockExecuteTool.mockResolvedValueOnce({ success: true, output: false }) + + await executeStreamingRequest(provider, model) + + const secondPayload = mockCreate.mock.calls[1][0] as { + messages: Array<{ role: string; content?: string }> + } + expect(secondPayload.messages).toContainEqual( + expect.objectContaining({ role: 'tool', content: 'false' }) + ) + } + ) + + it.each(CAPPED_PROVIDERS)( + '$name uses one tool-disabled synthesis when the iteration cap ends on a tool call', + async ({ provider, model, disablesTools }) => { + mockCreate + .mockResolvedValueOnce(response(null, [toolCall('call-1')])) + .mockResolvedValueOnce(response(null, [toolCall('call-2')])) + .mockResolvedValueOnce(response('cap synthesis')) + + const { events, result } = await readSettledEvents( + await executeStreamingRequest(provider, model) + ) + + expect(mockCreate).toHaveBeenCalledTimes(3) + const finalPayload = mockCreate.mock.calls[2][0] + expect(finalPayload.stream).toBeUndefined() + if (disablesTools === 'none') { + expect(finalPayload.tool_choice).toBe('none') + } else { + expect(finalPayload.tools).toBeUndefined() + expect(finalPayload.tool_choice).toBeUndefined() + } + expect(events).toEqual([{ type: 'text_delta', text: 'cap synthesis', turn: 'final' }]) + expectModelIterations(result, 3) + } + ) +}) diff --git a/apps/sim/providers/stream-events.test.ts b/apps/sim/providers/stream-events.test.ts index 6f497d5eb3f..76c93cd3373 100644 --- a/apps/sim/providers/stream-events.test.ts +++ b/apps/sim/providers/stream-events.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { type AgentStreamEvent, createAgentEventReadableStream, + createSettledAgentEventStream, isAgentStreamEvent, isTextDeltaTurn, isToolCallEndStatus, @@ -94,4 +95,14 @@ describe('stream-events contract', () => { await expect(reader.read()).rejects.toThrow(/Invalid AgentStreamEvent/) }) }) + + it('projects a settled answer without model regeneration', async () => { + const reader = createSettledAgentEventStream('settled answer').getReader() + + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'settled answer', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) + }) }) diff --git a/apps/sim/providers/stream-events.ts b/apps/sim/providers/stream-events.ts index f96608dc67d..09e460f730f 100644 --- a/apps/sim/providers/stream-events.ts +++ b/apps/sim/providers/stream-events.ts @@ -140,3 +140,13 @@ export function createAgentEventReadableStream( }, }) } + +/** + * Projects an already-settled provider answer onto the canonical stream without + * asking the model to generate it a second time. + */ +export function createSettledAgentEventStream(content: string): ReadableStream { + return createAgentEventReadableStream( + content ? [{ type: 'text_delta', text: content, turn: 'final' }] : [] + ) +} diff --git a/apps/sim/providers/streaming-execution.test.ts b/apps/sim/providers/streaming-execution.test.ts index f5d4c678932..83552be2883 100644 --- a/apps/sim/providers/streaming-execution.test.ts +++ b/apps/sim/providers/streaming-execution.test.ts @@ -135,9 +135,10 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) - it('only finalizes timing when the provider calls finalizeTiming', () => { + it('finalizes timing when the provider stream closes', async () => { const constructTime = 1_200 - vi.spyOn(Date, 'now').mockReturnValue(constructTime) + const drainTime = 4_500 + const nowMock = vi.spyOn(Date, 'now').mockReturnValue(constructTime) const result = createStreamingExecution({ model: 'no-finalize', @@ -155,13 +156,22 @@ describe('createStreamingExecution', () => { initialCost: { input: 0, output: 0, total: 0 }, createStream: ({ output }) => { output.content = 'no-timing-mutation' - return new ReadableStream() + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) }, }) + nowMock.mockReturnValue(drainTime) + await result.stream.getReader().read() + const timing = result.execution.output.providerTiming - expect(timing?.endTime).toBe(new Date(constructTime).toISOString()) - expect(timing?.duration).toBe(constructTime - providerStartTime) + expect(timing?.endTime).toBe(new Date(drainTime).toISOString()) + expect(timing?.duration).toBe(drainTime - providerStartTime) + expect(result.execution.metadata?.endTime).toBe(new Date(drainTime).toISOString()) + expect(result.execution.metadata?.duration).toBe(drainTime - providerStartTime) expect(result.execution.isStreaming).toBeUndefined() vi.restoreAllMocks() @@ -209,6 +219,35 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) + it('propagates cancellation and finalizes timing', async () => { + const constructTime = 1_100 + const cancelTime = 3_200 + const nowMock = vi.spyOn(Date, 'now').mockReturnValue(constructTime) + const onCancel = vi.fn() + + const result = createStreamingExecution({ + model: 'cancel-model', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'cancel-model' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: () => + new ReadableStream({ + cancel: onCancel, + }), + }) + + nowMock.mockReturnValue(cancelTime) + await result.stream.cancel('consumer disconnected') + + expect(onCancel).toHaveBeenCalledWith('consumer disconnected') + expect(result.execution.output.providerTiming?.duration).toBe(cancelTime - providerStartTime) + expect(result.execution.metadata?.duration).toBe(cancelTime - providerStartTime) + + vi.restoreAllMocks() + }) + it('defaults streamFormat to text and can attach agent-events-v1 object streams', async () => { const textResult = createStreamingExecution({ model: 'm', diff --git a/apps/sim/providers/streaming-execution.ts b/apps/sim/providers/streaming-execution.ts index ef020c5d328..9b8950090af 100644 --- a/apps/sim/providers/streaming-execution.ts +++ b/apps/sim/providers/streaming-execution.ts @@ -58,7 +58,11 @@ type StreamingTiming = SimpleTiming | AccumulatedTiming 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. */ + /** + * Overwrites placeholder timing from the drain timestamp. Providers may call + * this after populating output; the factory also guarantees finalization when + * the stream closes, errors, or is cancelled. + */ finalizeTiming: () => void } @@ -156,12 +160,36 @@ export function createStreamingExecution( providerTiming, cost: initialCost, } + const executionMetadata = { + startTime: providerStartTimeISO, + endTime: nowISO, + duration, + } const timingKind = timing.kind - const stream = createStream({ + let timingFinalized = false + const finalizeTimingOnce = () => { + if (timingFinalized) return + timingFinalized = true + finalizeTiming(output, providerStartTime, timingKind) + const finalizedTiming = output.providerTiming + if (finalizedTiming) { + executionMetadata.endTime = finalizedTiming.endTime + executionMetadata.duration = finalizedTiming.duration + } + } + const providerStream = createStream({ output, - finalizeTiming: () => finalizeTiming(output, providerStartTime, timingKind), + finalizeTiming: finalizeTimingOnce, }) + const stream = timingFinalized + ? providerStream + : streamFormat === 'agent-events-v1' + ? withTimingFinalization( + providerStream as ReadableStream, + finalizeTimingOnce + ) + : withTimingFinalization(providerStream as ReadableStream, finalizeTimingOnce) return { stream, @@ -170,16 +198,50 @@ export function createStreamingExecution( success: true, output, logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: nowISO, - duration, - }, + metadata: executionMetadata, ...(isStreaming ? { isStreaming: true } : {}), }, } } +/** + * Finalizes provider timing exactly once when a stream settles while preserving + * backpressure and cancellation on the provider's original stream. + */ +function withTimingFinalization( + stream: ReadableStream, + finalize: () => void +): ReadableStream { + const reader = stream.getReader() + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read() + if (done) { + finalize() + reader.releaseLock() + controller.close() + return + } + controller.enqueue(value) + } catch (error) { + finalize() + reader.releaseLock() + controller.error(error) + } + }, + async cancel(reason) { + try { + await reader.cancel(reason) + } finally { + finalize() + reader.releaseLock() + } + }, + }) +} + /** * Overwrites the placeholder timing with the drain timestamp. For the simple * path the first time segment is also finalized; for the accumulated path only diff --git a/apps/sim/providers/streaming-tool-loop-shared.test.ts b/apps/sim/providers/streaming-tool-loop-shared.test.ts new file mode 100644 index 00000000000..563a0ac2d41 --- /dev/null +++ b/apps/sim/providers/streaming-tool-loop-shared.test.ts @@ -0,0 +1,26 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { parseToolArguments } from '@/providers/streaming-tool-loop-shared' + +describe('parseToolArguments', () => { + it('returns JSON objects', () => { + expect(parseToolArguments('{"query":"sim"}', 'search')).toEqual({ query: 'sim' }) + }) + + it.each(['null', '[]', '"text"', '0', 'false'])( + 'rejects non-object JSON arguments: %s', + (argumentsJson) => { + expect(() => parseToolArguments(argumentsJson, 'search')).toThrow( + 'Arguments for tool "search" must be a JSON object' + ) + } + ) + + it('rejects malformed JSON with the tool name', () => { + expect(() => parseToolArguments('{"query":', 'search')).toThrow( + 'Invalid JSON arguments for tool "search"' + ) + }) +}) diff --git a/apps/sim/providers/streaming-tool-loop-shared.ts b/apps/sim/providers/streaming-tool-loop-shared.ts index 8b4dc9bf11d..5ac0c49d777 100644 --- a/apps/sim/providers/streaming-tool-loop-shared.ts +++ b/apps/sim/providers/streaming-tool-loop-shared.ts @@ -6,16 +6,18 @@ * provider-agnostic contract they share. */ +import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import type { NormalizedBlockOutput } from '@/executor/types' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' /** - * Providers with a live streaming tool loop wired. The executor consults this - * (instead of hardcoding provider ids) when deciding whether an agent-events - * run can stream tool lifecycle; providers not listed simply ignore - * `streamToolCalls` and keep their legacy loop. + * Providers with a live streaming tool loop wired. Generated documentation and + * capability reporting consume this set; providers select their own internal + * loop from request shape. Event exposure is controlled separately. */ export const STREAMING_TOOL_CALL_PROVIDERS: ReadonlySet = new Set([ + 'openai', 'anthropic', 'azure-anthropic', 'groq', @@ -25,11 +27,6 @@ export const STREAMING_TOOL_CALL_PROVIDERS: ReadonlySet = new Set([ 'bedrock', ]) -/** Whether a provider has a live streaming tool loop wired. */ -export function supportsStreamingToolCalls(providerId: string): boolean { - return STREAMING_TOOL_CALL_PROVIDERS.has(providerId) -} - /** Aggregate result reported by a streaming tool loop when its stream closes. */ export interface StreamingToolLoopComplete { content: string @@ -49,6 +46,25 @@ export function isAbortError(error: unknown): boolean { return name === 'AbortError' || name === 'APIUserAbortError' } +/** Parse provider-supplied tool arguments without accepting non-object JSON values. */ +export function parseToolArguments( + argumentsJson: string, + toolName: string +): Record { + let parsed: unknown + try { + parsed = JSON.parse(argumentsJson) + } catch (error) { + throw new Error(`Invalid JSON arguments for tool "${toolName}"`, { cause: error }) + } + + if (!isRecordLike(parsed)) { + throw new Error(`Arguments for tool "${toolName}" must be a JSON object`) + } + + return parsed +} + /** * Settle every open tool with a terminal status and clear the tracking map. * Called when a loop aborts, errors, or drains with tools still running so no @@ -64,3 +80,49 @@ export function settleOpenTools( } openTools.clear() } + +interface TerminateToolLoopOptions { + controller: ReadableStreamDefaultController + /** Tools that emitted `tool_call_start` without a matching end. */ + openTools: Map + /** The loop's abort controller fired — request abort or consumer cancel. */ + aborted: boolean + /** The stream consumer called `cancel()`; the controller is already closed. */ + consumerCancelled: boolean + error: unknown + /** Invoked only for a genuine failure, before the stream is errored. */ + onUnexpectedError?: (error: unknown) => void +} + +/** + * Terminates a streaming tool loop that threw, settling any still-open tools. + * + * A consumer `cancel()` leaves the controller in the *closed* state, where + * `enqueue` and `close` both throw `TypeError: Invalid state`. That state is + * indistinguishable via `desiredSize` — it reports `0` when closed and `null` + * only when errored — so loops must track the cancel explicitly and stop + * writing. Every provider loop routes its catch block through here so the + * five of them cannot drift. + */ +export function terminateToolLoop({ + controller, + openTools, + aborted, + consumerCancelled, + error, + onUnexpectedError, +}: TerminateToolLoopOptions): void { + if (consumerCancelled) { + return + } + + if (aborted) { + settleOpenTools(controller, openTools, 'cancelled') + controller.close() + return + } + + settleOpenTools(controller, openTools, 'error') + onUnexpectedError?.(error) + controller.error(toError(error)) +} diff --git a/apps/sim/providers/together/index.test.ts b/apps/sim/providers/together/index.test.ts index 9d5386331cc..40624dad957 100644 --- a/apps/sim/providers/together/index.test.ts +++ b/apps/sim/providers/together/index.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' const { mockCreate, @@ -40,7 +41,9 @@ vi.mock('@/providers/attachments', () => ({ vi.mock('@/providers/together/utils', () => ({ supportsNativeStructuredOutputs: mockSupportsNativeStructuredOutputs, - createReadableStreamFromOpenAIStream: vi.fn(() => ({}) as ReadableStream), + createReadableStreamFromOpenAIStream: vi.fn( + () => new ReadableStream({ start: (controller) => controller.close() }) + ), checkForForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), })) @@ -66,11 +69,12 @@ const textResponse = (content: string) => ({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }) -const toolCallResponse = () => ({ +const toolCallResponse = (assistant: { content?: string | null; reasoning?: string } = {}) => ({ choices: [ { message: { - content: null, + content: assistant.content ?? null, + ...(assistant.reasoning !== undefined ? { reasoning: assistant.reasoning } : {}), tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'my_tool', arguments: '{"x":1}' } }, ], @@ -217,15 +221,54 @@ describe('togetherProvider', () => { ) }) - it("forces tool_choice 'none' on the final streaming call after tools run", async () => { + it('replays Together assistant content and reasoning on the second request', async () => { mockCreate - .mockResolvedValueOnce(toolCallResponse()) - .mockResolvedValueOnce(textResponse('done')) - .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + toolCallResponse({ + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + }) + ) + .mockResolvedValueOnce(textResponse('final answer')) + + await togetherProvider.executeRequest({ ...baseRequest, tools: [toolDef] }) + + expect( + callBody(1).messages.find((message: { role: string }) => message.role === 'assistant') + ).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Need the tool result.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'my_tool', arguments: '{"x":1}' }, + }, + ], + }) + }) - await togetherProvider.executeRequest({ ...baseRequest, stream: true, tools: [toolDef] }) + it('streams the settled tool-loop answer without a duplicate provider request', async () => { + mockCreate.mockResolvedValueOnce(toolCallResponse()).mockResolvedValueOnce(textResponse('done')) - expect(mockCreate).toHaveBeenCalledTimes(3) - expect(lastCallBody()).toMatchObject({ tool_choice: 'none', stream: true }) + const result = (await togetherProvider.executeRequest({ + ...baseRequest, + stream: true, + tools: [toolDef], + })) as StreamingExecution + + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(result.execution.output).toMatchObject({ + content: 'done', + tokens: { input: 18, output: 9, total: 27 }, + toolCalls: { count: 1 }, + }) + const reader = result.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: { type: 'text_delta', text: 'done', turn: 'final' }, + }) + await expect(reader.read()).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index c52f383d2ff..d8f67bb1435 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' 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 { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { checkForForcedToolUsage, createReadableStreamFromOpenAIStream, @@ -263,10 +267,25 @@ export const togetherProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -284,6 +303,9 @@ export const togetherProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call (Together):', { error: toError(error).message, @@ -306,26 +328,21 @@ export const togetherProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -336,10 +353,12 @@ export const togetherProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any + let resultContent: unknown if (result.success) { - toolResults.push(result.output!) - resultContent = result.output + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -417,85 +436,61 @@ export const togetherProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { - enrichLastModelSegmentFromChatCompletions( - timeSegments, - currentResponse, - currentResponse.choices[0]?.message?.tool_calls, - { model: request.model, provider: 'together' } - ) - } + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls + enrichLastModelSegmentFromChatCompletions(timeSegments, currentResponse, pendingToolCalls, { + model: request.model, + provider: 'together', + }) - if (request.stream) { - const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + if (pendingToolCalls?.length && !(request.responseFormat && hasActiveTools)) { + const finalPayload: any = { + ...payload, + messages: [...currentMessages], + tool_choice: 'none', + } - const streamingParams: ChatCompletionCreateParamsStreaming = { - ...payload, - messages: [...currentMessages], - tool_choice: 'none', - stream: true, - stream_options: { include_usage: true }, - } + if (request.responseFormat) { + finalPayload.messages = await applyResponseFormat( + finalPayload, + finalPayload.messages, + request.responseFormat, + requestedModel + ) + } - if (request.responseFormat) { - ;(streamingParams as any).messages = await applyResponseFormat( - streamingParams as any, - streamingParams.messages, - request.responseFormat, - requestedModel + const finalStartTime = Date.now() + const finalResponse = await client.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined ) - } - - const streamResponse = await client.chat.completions.create( - streamingParams, - request.abortSignal ? { signal: request.abortSignal } : undefined - ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime - 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, - streamFormat: 'agent-events-v1', - 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, - } + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + startTime: finalStartTime, + endTime: finalEndTime, + duration: finalDuration, + }) + modelTime += finalDuration - 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, - } - }), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + finalResponse, + finalResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'together' } + ) + } } if (request.responseFormat && hasActiveTools) { @@ -551,6 +546,45 @@ export const togetherProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(requestedModel, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } + + const streamingResult = createStreamingExecution({ + model: requestedModel, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total }, + initialCost: finalCost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -568,7 +602,7 @@ export const togetherProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -591,6 +625,10 @@ export const togetherProvider: ProviderConfig = { } logger.error('Error in Together request:', errorDetails) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index b69517a7bb0..caa82abf61e 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -1,5 +1,9 @@ import type { BlockTokens, IterationToolCall, ProviderTimingSegment } from '@/executor/types' -import { calculateCost } from '@/providers/utils' +import { LIST_PRICE_POLICY, priceModelUsage } from '@/providers/cost-policy' +import { + getOpenRouterReasoningDetailText, + type OpenRouterReasoningDetail, +} from '@/providers/openrouter/reasoning' /** * Minimal structural shape shared by OpenAI Chat Completions and every @@ -15,10 +19,7 @@ interface ChatCompletionLike { tool_calls?: Array | null reasoning_content?: string | null reasoning?: string | null - reasoning_details?: Array<{ - text?: string | null - summary?: string | null - } | null> | null + reasoning_details?: OpenRouterReasoningDetail[] | null } | null finish_reason?: string | null } | null> @@ -141,8 +142,8 @@ function extractChatCompletionsReasoning( } if (Array.isArray(message.reasoning_details)) { const joined = message.reasoning_details - .map((d) => d?.text ?? d?.summary ?? '') - .filter((s): s is string => typeof s === 'string' && s.length > 0) + .map(getOpenRouterReasoningDetailText) + .filter((text) => text.length > 0) .join('\n') if (joined.length > 0) return joined } @@ -193,7 +194,17 @@ export function enrichLastModelSegmentFromChatCompletions( let derivedCost = extras?.cost if (!derivedCost && extras?.model && promptTokens != null && completionTokens != null) { - const full = calculateCost(extras.model, promptTokens, completionTokens, cacheRead > 0) + // OpenAI-compatible vendors report cached tokens as a subset of the prompt + // total, so the uncached remainder is the subtraction. + const full = priceModelUsage( + extras.model, + { + input: Math.max(0, promptTokens - cacheRead), + output: completionTokens, + cacheRead, + }, + LIST_PRICE_POLICY + ) derivedCost = { input: full.input, output: full.output, total: full.total } } diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 9d810b7961c..2bb1a9d31c0 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -67,7 +67,7 @@ export interface FunctionCallResponse { startTime?: string endTime?: string duration?: number - result?: Record + result?: unknown output?: Record input?: Record success?: boolean @@ -84,9 +84,14 @@ export interface ProviderResponse { content: string model: string tokens?: { + /** Tokens billed at the base input rate, excluding cache reads and writes. */ input?: number output?: number total?: number + /** Input tokens served from the provider's prompt cache. */ + cacheRead?: number + /** Input tokens written to the provider's prompt cache. */ + cacheWrite?: number } toolCalls?: FunctionCallResponse[] toolResults?: Record[] @@ -168,17 +173,11 @@ export interface ProviderRequest { chatId?: string userId?: string stream?: boolean - /** - * Use the live streaming tool loop (tool lifecycle on the agent-events - * stream). Set by the executor only for agent-events runs on providers in - * {@link STREAMING_TOOL_CALL_PROVIDERS}; providers without a loop ignore it. - */ - streamToolCalls?: boolean /** * Run-level agent-events opt-in. Lets providers request streamable thinking - * (e.g. OpenAI reasoning summaries, Gemini thought summaries) for the run. - * Never changes answer content; when unset, provider requests are identical - * to the pre-agent-events wire shape. + * (e.g. OpenAI reasoning summaries, Gemini thought summaries) and lets opted-in + * consumers observe the event timeline. It does not select the internal tool + * loop and never changes answer content. */ agentEvents?: boolean environmentVariables?: Record @@ -197,6 +196,14 @@ export interface ProviderRequest { reasoningEffort?: string verbosity?: string thinkingLevel?: string + /** + * Opt in to caller-placed prompt-cache breakpoints on the static prefix. + * Only meaningful for models declaring `capabilities.promptCaching`; + * `sanitizeRequest` clears it otherwise. + */ + promptCaching?: boolean + /** Stable identity of the block issuing the request, used for cache routing. */ + blockId?: string isDeployedContext?: boolean callChain?: string[] /** diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index cf734280678..0b10c2bf511 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -393,7 +393,7 @@ describe('Model Capabilities', () => { expect(supportsThinking('claude-sonnet-4-5')).toBe(true) expect(supportsThinking('claude-haiku-4-5')).toBe(true) expect(supportsThinking('gemini-3-flash-preview')).toBe(true) - expect(supportsThinking('deepseek-chat')).toBe(true) + expect(supportsThinking('deepseek-v4-flash')).toBe(true) expect(supportsThinking('deepseek-reasoner')).toBe(true) expect(supportsThinking('groq/qwen/qwen3.6-27b')).toBe(true) }) @@ -402,6 +402,7 @@ describe('Model Capabilities', () => { expect(supportsThinking('gpt-4o')).toBe(false) expect(supportsThinking('gpt-5')).toBe(false) expect(supportsThinking('o3')).toBe(false) + expect(supportsThinking('deepseek-chat')).toBe(false) expect(supportsThinking('deepseek-v3')).toBe(false) expect(supportsThinking('unknown-model')).toBe(false) }) @@ -515,8 +516,9 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_THINKING).toContain('claude-haiku-4-5') - expect(MODELS_WITH_THINKING).toContain('deepseek-chat') + expect(MODELS_WITH_THINKING).toContain('deepseek-v4-flash') expect(MODELS_WITH_THINKING).toContain('deepseek-reasoner') + expect(MODELS_WITH_THINKING).not.toContain('deepseek-chat') expect(MODELS_WITH_THINKING).toContain('groq/qwen/qwen3.6-27b') expect(MODELS_WITH_THINKING).not.toContain('gpt-4o') @@ -703,8 +705,11 @@ describe('Max Output Tokens', () => { expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000) }) + it('should return published max for DeepSeek Reasoner', () => { + expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(384000) + }) + it('should return standard default for models without maxOutputTokens', () => { - expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096) expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index bb6ce810ff0..05d60e37a12 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -32,6 +32,7 @@ import { getModelPricing as getModelPricingFromDefinitions, getModelsWithDeepResearch, getModelsWithoutMemory, + getModelsWithPromptCaching, getModelsWithReasoningEffort, getModelsWithTemperatureRange, getModelsWithTemperatureSupport, @@ -1320,6 +1321,7 @@ export const MODELS_WITH_TEMPERATURE_SUPPORT = getModelsWithTemperatureSupport() export const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort() export const MODELS_WITH_VERBOSITY = getModelsWithVerbosity() export const MODELS_WITH_THINKING = getModelsWithThinking() +export const MODELS_WITH_PROMPT_CACHING = getModelsWithPromptCaching() export const MODELS_WITH_DEEP_RESEARCH = getModelsWithDeepResearch() export const MODELS_WITHOUT_MEMORY = getModelsWithoutMemory() export const PROVIDERS_WITH_TOOL_USAGE_CONTROL = getProvidersWithToolUsageControl() @@ -1340,6 +1342,11 @@ export function supportsThinking(model: string): boolean { return MODELS_WITH_THINKING.includes(model.toLowerCase()) } +/** Whether the model accepts caller-placed prompt-cache breakpoints. */ +export function supportsPromptCaching(model: string): boolean { + return MODELS_WITH_PROMPT_CACHING.includes(model.toLowerCase()) +} + export function isDeepResearchModel(model: string): boolean { return MODELS_WITH_DEEP_RESEARCH.includes(model.toLowerCase()) } diff --git a/apps/sim/providers/vllm/index.test.ts b/apps/sim/providers/vllm/index.test.ts index ed6fa3de8f1..342067d8022 100644 --- a/apps/sim/providers/vllm/index.test.ts +++ b/apps/sim/providers/vllm/index.test.ts @@ -75,6 +75,7 @@ vi.mock('@/stores/providers', () => ({ })) import { clearProviderClientCacheForTests } from '@/providers/client-cache' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderToolConfig } from '@/providers/types' import { vllmProvider } from '@/providers/vllm/index' @@ -84,9 +85,13 @@ interface ToolCall { function: { name: string; arguments: string } } -function chatResponse(content: string | null, toolCalls?: ToolCall[]) { +function chatResponse( + content: string | null, + toolCalls?: ToolCall[], + reasoning?: { reasoning?: string; reasoning_content?: string } +) { return { - choices: [{ message: { content, tool_calls: toolCalls } }], + choices: [{ message: { content, tool_calls: toolCalls, ...reasoning } }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, } } @@ -110,6 +115,16 @@ const toolCall = (id: string, name: string, args = '{}'): ToolCall => ({ /** Payload passed to the Nth `chat.completions.create` call. */ const createPayload = (callIndex: number) => mockCreate.mock.calls[callIndex][0] +async function readAgentEvents(stream: ReadableStream) { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value) + } +} + afterAll(resetEnvMock) describe('vllmProvider', () => { @@ -281,6 +296,40 @@ describe('vllmProvider', () => { expect(result.toolResults).toHaveLength(1) }) + it('replays vLLM assistant content and emitted reasoning fields on the second request', async () => { + mockCreate + .mockResolvedValueOnce( + chatResponse('I will use the tool.', [toolCall('call_1', 'myTool', '{"x":1}')], { + reasoning: 'Current vLLM reasoning.', + reasoning_content: 'Legacy vLLM reasoning.', + }) + ) + .mockResolvedValueOnce(chatResponse('final answer')) + + await vllmProvider.executeRequest({ + model: 'vllm/llama-3', + messages: [{ role: 'user', content: 'use a tool' }], + tools: [makeTool('myTool')], + }) + + const assistant = createPayload(1).messages.find( + (message: { role: string }) => message.role === 'assistant' + ) + expect(assistant).toEqual({ + role: 'assistant', + content: 'I will use the tool.', + reasoning: 'Current vLLM reasoning.', + reasoning_content: 'Legacy vLLM reasoning.', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'myTool', arguments: '{"x":1}' }, + }, + ], + }) + }) + it('records a failed tool result without throwing', async () => { mockExecuteTool.mockResolvedValueOnce({ success: false, error: 'tool blew up' }) mockCreate @@ -353,19 +402,33 @@ describe('vllmProvider', () => { 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({}) + it('projects the settled tool-loop answer without a final streaming call', async () => { + mockCreate + .mockResolvedValueOnce(chatResponse(null, [toolCall('call_1', 'myTool')])) + .mockResolvedValueOnce(chatResponse('answer')) - await vllmProvider.executeRequest({ + const result = 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') + expect(mockCreate).toHaveBeenCalledTimes(2) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) + expect('stream' in result).toBe(true) + if (!('stream' in result)) throw new Error('Expected streaming execution') + expect(result.execution.output.content).toBe('answer') + expect(result.execution.output.tokens).toEqual({ input: 20, output: 10, total: 30 }) + expect(result.execution.output.providerTiming?.iterations).toBe(2) + expect( + result.execution.output.providerTiming?.timeSegments?.filter( + (segment) => segment.type === 'model' + ) + ).toHaveLength(2) + await expect( + readAgentEvents(result.stream as ReadableStream) + ).resolves.toEqual([{ type: 'text_delta', text: 'answer', turn: 'final' }]) }) it('throws a ProviderError carrying the vLLM error message on API failure', async () => { diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 34ee8d9f256..0f258b8bb22 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions' import { env } from '@/lib/core/config/env' @@ -9,7 +10,10 @@ import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { getCachedProviderClient } from '@/providers/client-cache' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -359,10 +363,25 @@ export const vllmProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) - if (!tool) return null + 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, { @@ -380,6 +399,9 @@ export const vllmProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -399,26 +421,21 @@ export const vllmProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning', 'reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -429,10 +446,12 @@ export const vllmProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -535,26 +554,58 @@ export const vllmProvider: ProviderConfig = { currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: 'vllm' } ) + + if (currentResponse.choices[0]?.message?.tool_calls?.length) { + /** + * The capped turn still requests tools, so make one tool-disabled call + * to synthesize an answer from the tool results already gathered. + */ + const { tools: _tools, tool_choice: _toolChoice, ...synthesisPayload } = payload + const synthesisStartTime = Date.now() + const synthesisResponse = await vllm.chat.completions.create( + { + ...synthesisPayload, + messages: currentMessages, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const synthesisEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool limit', + startTime: synthesisStartTime, + endTime: synthesisEndTime, + duration: synthesisEndTime - synthesisStartTime, + }) + modelTime += synthesisEndTime - synthesisStartTime + + content = synthesisResponse.choices[0]?.message?.content || content + if (content && request.responseFormat) { + content = content.replace(/```json\n?|\n?```/g, '').trim() + } + if (synthesisResponse.usage) { + tokens.input += synthesisResponse.usage.prompt_tokens || 0 + tokens.output += synthesisResponse.usage.completion_tokens || 0 + tokens.total += synthesisResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + synthesisResponse, + synthesisResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'vllm' } + ) + } } if (request.stream) { - logger.info('Using streaming for final response after tool processing') + logger.info('Projecting settled response after tool processing') const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) - 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({ + return createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, @@ -563,7 +614,7 @@ export const vllmProvider: ProviderConfig = { modelTime, toolsTime, firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments, }, initialTokens: { @@ -574,7 +625,8 @@ export const vllmProvider: ProviderConfig = { initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, - total: accumulatedCost.total, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, }, toolCalls: toolCalls.length > 0 @@ -584,36 +636,12 @@ export const vllmProvider: ProviderConfig = { } : undefined, streamFormat: 'agent-events-v1', - 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, - } - }), + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) - - return streamingResult } const providerEndTime = Date.now() @@ -633,7 +661,7 @@ export const vllmProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -662,6 +690,10 @@ export const vllmProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 974bbe87565..55c6ecdaecb 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -1,15 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' -import type { - ChatCompletionChunk, - ChatCompletionCreateParamsStreaming, -} from 'openai/resources/chat/completions' +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 { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -248,12 +249,25 @@ export const xAIProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { logger.warn('XAI Provider - Tool not found:', { toolName }) - return null + 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) @@ -272,6 +286,9 @@ export const xAIProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('XAI Provider - Error processing tool call:', { error: toError(error).message, @@ -294,25 +311,21 @@ export const xAIProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -322,10 +335,12 @@ export const xAIProvider: ProviderConfig = { duration: duration, toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -453,52 +468,78 @@ export const xAIProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const pendingToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + pendingToolCalls, { model: request.model, provider: 'xai' } ) + + if (pendingToolCalls?.length) { + const finalPayload = request.responseFormat + ? createResponseFormatPayload( + basePayload, + allMessages, + request.responseFormat, + currentMessages + ) + : { + ...basePayload, + messages: currentMessages, + tools: preparedTools?.tools, + tool_choice: 'none', + } + const finalStartTime = Date.now() + const finalResponse = await xai.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalEndTime = Date.now() + const finalDuration = finalEndTime - finalStartTime + + timeSegments.push({ + type: 'model', + name: 'Final answer after tool iteration limit', + 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: 'xai' } + ) + } } } catch (error) { logger.error('XAI Provider - Error in tool processing loop:', { error: toError(error).message, iterationCount, }) + throw error } if (request.stream) { - let finalStreamingPayload: any - - if (request.responseFormat) { - finalStreamingPayload = { - ...createResponseFormatPayload( - basePayload, - allMessages, - request.responseFormat, - currentMessages - ), - stream: true, - } - } else { - /** - * The regeneration exists purely to stream the settled answer as - * prose — streamed tool_calls are never executed on this path. - */ - finalStreamingPayload = { - ...basePayload, - messages: currentMessages, - tool_choice: 'none', - 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 toolCost = sumToolCosts(toolResults) + const finalCost = { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + } const streamingResult = createStreamingExecution({ model: request.model, @@ -517,12 +558,7 @@ export const xAIProvider: ProviderConfig = { output: tokens.output, total: tokens.total, }, - initialCost: { - input: accumulatedCost.input, - output: accumulatedCost.output, - toolCost: undefined as number | undefined, - total: accumulatedCost.total, - }, + initialCost: finalCost, toolCalls: toolCalls.length > 0 ? { @@ -532,35 +568,13 @@ export const xAIProvider: ProviderConfig = { : undefined, isStreaming: true, streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromXAIStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (streamedContent, usage) => { - if (!streamedContent && content) { - logger.warn('xAI final stream produced no text; keeping tool-loop answer') - } - output.content = streamedContent || 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, - } - } - ), + createStream: ({ output, finalizeTiming }) => { + output.content = content + output.tokens = { input: tokens.input, output: tokens.output, total: tokens.total } + output.cost = finalCost + finalizeTiming() + return createSettledAgentEventStream(content) + }, }) return streamingResult @@ -606,6 +620,10 @@ export const xAIProvider: ProviderConfig = { hasResponseFormat: !!request.responseFormat, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index e8c960e1a81..39358ef774a 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import OpenAI from 'openai' import type { ChatCompletionChunk } 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 { createOpenAICompatAssistantHistory } from '@/providers/openai-compat/assistant-history' +import { createSettledAgentEventStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' +import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { @@ -21,7 +25,6 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, - trackForcedToolUsage, } from '@/providers/utils' import { createReadableStreamFromZaiStream } from '@/providers/zai/utils' import { executeTool } from '@/tools' @@ -156,6 +159,7 @@ export const zaiProvider: ProviderConfig = { } const deferResponseFormat = !!responseFormatPayload && hasActiveTools + let appliedDeferredResponseFormat = false if (responseFormatPayload && !deferResponseFormat) { payload.response_format = responseFormatPayload payload.messages = withSchemaGuidance( @@ -215,9 +219,6 @@ export const zaiProvider: ProviderConfig = { } const initialCallTime = Date.now() - const originalToolChoice = payload.tool_choice - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] let currentResponse = await zai.chat.completions.create( payload, @@ -236,7 +237,6 @@ export const zaiProvider: ProviderConfig = { const toolResults: Record[] = [] const currentMessages = [...formattedMessages] let iterationCount = 0 - let hasUsedForcedTool = false let modelTime = firstResponseTime let toolsTime = 0 @@ -250,23 +250,6 @@ export const zaiProvider: ProviderConfig = { }, ] - 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) { @@ -293,7 +276,7 @@ export const zaiProvider: ProviderConfig = { const toolName = toolCall.function.name try { - const toolArgs = JSON.parse(toolCall.function.arguments) + const toolArgs = parseToolArguments(toolCall.function.arguments, toolName) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) { @@ -329,6 +312,9 @@ export const zaiProvider: ProviderConfig = { duration: toolCallEndTime - toolCallStartTime, } } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) @@ -348,26 +334,21 @@ export const zaiProvider: ProviderConfig = { } }) - 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 executionResults = await Promise.all(toolExecutionPromises) + const assistantMessage = currentResponse.choices[0]?.message + if (assistantMessage) { + currentMessages.push( + createOpenAICompatAssistantHistory({ + message: assistantMessage, + toolCalls: toolCallsInResponse, + reasoningFields: ['reasoning_content'], + }) + ) + } + for (const executionResult of executionResults) { const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = - settledResult.value + executionResult timeSegments.push({ type: 'tool', @@ -378,10 +359,12 @@ export const zaiProvider: ProviderConfig = { toolCallId: toolCall.id, }) - let resultContent: any - if (result.success && result.output) { - toolResults.push(result.output) - resultContent = result.output + let resultContent: unknown + if (result.success) { + if (isRecordLike(result.output)) { + toolResults.push(result.output) + } + resultContent = result.output ?? null } else { resultContent = { error: true, @@ -415,48 +398,12 @@ export const zaiProvider: ProviderConfig = { 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 @@ -484,108 +431,71 @@ export const zaiProvider: ProviderConfig = { } if (iterationCount === MAX_TOOL_ITERATIONS) { + const cappedToolCalls = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, - currentResponse.choices[0]?.message?.tool_calls, + cappedToolCalls, { 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 - ) + if (cappedToolCalls?.length) { + const finalPayload: any = { + ...payload, + messages: currentMessages, + } + finalPayload.tools = undefined + finalPayload.tool_choice = undefined + if (deferResponseFormat && responseFormatPayload) { + finalPayload.response_format = responseFormatPayload + finalPayload.messages = withSchemaGuidance( + finalPayload.messages, + buildSchemaGuidance(request.responseFormat) + ) + appliedDeferredResponseFormat = true + } - const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const finalModelStartTime = Date.now() + currentResponse = await zai.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const finalModelEndTime = Date.now() + const finalModelDuration = finalModelEndTime - finalModelStartTime - 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, - streamFormat: 'agent-events-v1', - createStream: ({ output }) => - createReadableStreamFromZaiStream( - // double-cast-allowed: payload is untyped so the SDK cannot resolve the streaming overload; the stream yields OpenAI ChatCompletionChunk objects - streamResponse as unknown as AsyncIterable, - (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, - } + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalModelStartTime, + endTime: finalModelEndTime, + duration: finalModelDuration, + }) + modelTime += finalModelDuration - 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, - } - } - ), - }) + 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 + } - return streamingResult + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'zai' } + ) + iterationCount++ + } + } + } catch (error) { + logger.error('Error in Z.ai request:', { error }) + throw error } - if (deferResponseFormat && responseFormatPayload) { + if (deferResponseFormat && responseFormatPayload && !appliedDeferredResponseFormat) { logger.info('Applying deferred response_format after tool processing') const finalFormatStartTime = Date.now() @@ -634,6 +544,52 @@ export const zaiProvider: ProviderConfig = { ) } + if (request.stream) { + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCost = sumToolCosts(toolResults) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: toolCost || undefined, + total: accumulatedCost.total + toolCost, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => { + output.content = content + finalizeTiming() + return createSettledAgentEventStream(content) + }, + }) + + return streamingResult + } + const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime @@ -651,7 +607,7 @@ export const zaiProvider: ProviderConfig = { modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, + iterations: timeSegments.filter((segment) => segment.type === 'model').length, timeSegments: timeSegments, }, } @@ -665,6 +621,10 @@ export const zaiProvider: ProviderConfig = { duration: totalDuration, }) + if (isAbortError(error) || request.abortSignal?.aborted) { + throw error + } + throw new ProviderError(toError(error).message, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, diff --git a/apps/sim/tools/firecrawl/batch-scrape.ts b/apps/sim/tools/firecrawl/batch-scrape.ts index 933c37d22ae..d3b7df2090c 100644 --- a/apps/sim/tools/firecrawl/batch-scrape.ts +++ b/apps/sim/tools/firecrawl/batch-scrape.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { FirecrawlBatchScrapeParams, FirecrawlBatchScrapeResponse, @@ -101,33 +102,7 @@ export const batchScrapeTool: ToolConfig { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', @@ -214,7 +189,10 @@ export const batchScrapeTool: ToolConfig }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { url: 'https://api.firecrawl.dev/v2/crawl', @@ -181,7 +156,10 @@ export const crawlTool: ToolConfig result.output = { pages: crawlData.data || [], total: crawlData.total || 0, - creditsUsed: crawlData.creditsUsed || 0, + // Forwarded as-is: defaulting a missing count to 0 would look like + // a free crawl to the hosted-key pricing helper instead of the + // metering failure it is. + creditsUsed: crawlData.creditsUsed, } return result } diff --git a/apps/sim/tools/firecrawl/extract.ts b/apps/sim/tools/firecrawl/extract.ts index c51c19d1886..fd9c71e6a2d 100644 --- a/apps/sim/tools/firecrawl/extract.ts +++ b/apps/sim/tools/firecrawl/extract.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ExtractParams, ExtractResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -80,33 +81,7 @@ export const extractTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/hosting.test.ts b/apps/sim/tools/firecrawl/hosting.test.ts new file mode 100644 index 00000000000..6229f671be5 --- /dev/null +++ b/apps/sim/tools/firecrawl/hosting.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { batchScrapeTool } from '@/tools/firecrawl/batch-scrape' +import { crawlTool } from '@/tools/firecrawl/crawl' +import { extractTool } from '@/tools/firecrawl/extract' +import { FIRECRAWL_CREDIT_USD } from '@/tools/firecrawl/hosting' +import { mapTool } from '@/tools/firecrawl/map' +import { parseTool } from '@/tools/firecrawl/parse' +import { scrapeTool } from '@/tools/firecrawl/scrape' +import { searchTool } from '@/tools/firecrawl/search' + +/** The slice of a tool config this suite exercises, free of each tool's param type. */ +interface HostedTool { + hosting?: { + envKeyPrefix: string + apiKeyParam: string + byokProviderId?: string + rateLimit: { mode: string } + pricing: + | { type: 'per_request'; cost: number } + | { + type: 'custom' + getCost: ( + params: never, + output: Record + ) => number | { cost: number; metadata?: Record } + } + } +} + +const HOSTED_TOOLS: Array<[string, HostedTool]> = [ + ['scrape', scrapeTool], + ['search', searchTool], + ['crawl', crawlTool], + ['map', mapTool], + ['extract', extractTool], + ['parse', parseTool], + ['batchScrape', batchScrapeTool], +] + +function getCost(tool: HostedTool, output: Record) { + const pricing = tool.hosting?.pricing + if (pricing?.type !== 'custom') throw new Error('expected custom pricing') + return pricing.getCost(undefined as never, output) +} + +describe('firecrawl hosted-key config', () => { + it.each(HOSTED_TOOLS)('%s resolves a complete hosting config', (_name, tool) => { + expect(tool.hosting).toMatchObject({ + envKeyPrefix: 'FIRECRAWL_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'firecrawl', + rateLimit: { mode: 'per_request', requestsPerMinute: 100 }, + }) + expect(tool.hosting?.pricing.type).toBe('custom') + }) + + it('bills credits reported on the response envelope', () => { + expect(getCost(searchTool, { creditsUsed: 4 })).toEqual({ + cost: 4 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 4 }, + }) + }) + + it('bills credits reported on the returned document metadata', () => { + // Firecrawl reports per-page usage on the document rather than the + // envelope; reading only the envelope left every scrape and parse unbilled. + expect(getCost(scrapeTool, { metadata: { creditsUsed: 5 } })).toEqual({ + cost: 5 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 5 }, + }) + + expect(getCost(parseTool, { metadata: { creditsUsed: 12 } })).toEqual({ + cost: 12 * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed: 12 }, + }) + }) + + it.each(HOSTED_TOOLS)('%s rejects a response with no usable credit count', (_name, tool) => { + expect(() => getCost(tool, {})).toThrow(/creditsUsed/) + expect(() => getCost(tool, { creditsUsed: 'many' })).toThrow(/non-numeric/) + }) +}) diff --git a/apps/sim/tools/firecrawl/hosting.ts b/apps/sim/tools/firecrawl/hosting.ts new file mode 100644 index 00000000000..fc8c5d0559b --- /dev/null +++ b/apps/sim/tools/firecrawl/hosting.ts @@ -0,0 +1,68 @@ +import type { ToolHostingConfig } from '@/tools/types' + +/** Env var prefix for Firecrawl hosted keys. */ +export const FIRECRAWL_API_KEY_PREFIX = 'FIRECRAWL_API_KEY' + +/** + * Dollar cost of a single Firecrawl credit on Sim's hosted plan. + * + * Firecrawl is subscription-priced rather than pay-as-you-go, so this is the + * effective per-credit rate of the plan the hosted keys belong to. + * + * Source: https://www.firecrawl.dev/pricing + */ +export const FIRECRAWL_CREDIT_USD = 0.001 + +/** + * Reads the credits a Firecrawl response reported. + * + * Firecrawl reports usage in two documented places: job- and search-style + * endpoints put `creditsUsed` on the response envelope, while per-page + * endpoints put it on the returned document's metadata. Both are checked so a + * tool can never be wired to the wrong one. + * + * Source: https://docs.firecrawl.dev/billing + */ +function readReportedCredits(output: Record): unknown { + const fromEnvelope = output.creditsUsed + if (fromEnvelope != null) return fromEnvelope + return (output.metadata as { creditsUsed?: unknown } | undefined)?.creditsUsed +} + +/** + * Builds the Firecrawl `hosting` config shared by every Firecrawl tool. + * + * All Firecrawl operations are usage-priced — option modifiers, page counts, + * and result bands each change the credit count — so the charge always comes + * from the credits the API reported rather than a fixed per-request rate. + */ +export function firecrawlHosting

(): ToolHostingConfig

{ + return { + envKeyPrefix: FIRECRAWL_API_KEY_PREFIX, + apiKeyParam: 'apiKey', + byokProviderId: 'firecrawl', + pricing: { + type: 'custom', + getCost: (_params, output) => { + const reported = readReportedCredits(output) + if (reported == null) { + throw new Error('Firecrawl response missing creditsUsed field') + } + + const creditsUsed = Number(reported) + if (!Number.isFinite(creditsUsed)) { + throw new Error('Firecrawl response returned a non-numeric creditsUsed field') + } + + return { + cost: creditsUsed * FIRECRAWL_CREDIT_USD, + metadata: { creditsUsed }, + } + }, + }, + rateLimit: { + mode: 'per_request', + requestsPerMinute: 100, + }, + } +} diff --git a/apps/sim/tools/firecrawl/map.ts b/apps/sim/tools/firecrawl/map.ts index e5a507556e2..1fd8a6a0376 100644 --- a/apps/sim/tools/firecrawl/map.ts +++ b/apps/sim/tools/firecrawl/map.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { MapParams, MapResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -66,33 +67,7 @@ export const mapTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/parse.ts b/apps/sim/tools/firecrawl/parse.ts index 756c53b36a3..a19acebfb9c 100644 --- a/apps/sim/tools/firecrawl/parse.ts +++ b/apps/sim/tools/firecrawl/parse.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ParseParams, ParseResponse } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -83,33 +84,7 @@ export const parseTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - const creditsUsed = (output.metadata as { creditsUsed?: number })?.creditsUsed - if (creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', @@ -168,6 +143,7 @@ export const parseTool: ToolConfig = { links: result.links ?? [], metadata: result.metadata ?? null, warning: result.warning ?? null, + creditsUsed: result.creditsUsed, }, } }, diff --git a/apps/sim/tools/firecrawl/scrape.ts b/apps/sim/tools/firecrawl/scrape.ts index bdd8ab9d6f4..8246bd770f6 100644 --- a/apps/sim/tools/firecrawl/scrape.ts +++ b/apps/sim/tools/firecrawl/scrape.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { ScrapeParams, ScrapeResponse } from '@/tools/firecrawl/types' import { PAGE_METADATA_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types' import { safeAssign } from '@/tools/safe-assign' @@ -31,33 +32,7 @@ export const scrapeTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - const creditsUsed = (output.metadata as { creditsUsed?: number })?.creditsUsed - if (creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/firecrawl/search.ts b/apps/sim/tools/firecrawl/search.ts index 38f93955a23..b5cae84bc50 100644 --- a/apps/sim/tools/firecrawl/search.ts +++ b/apps/sim/tools/firecrawl/search.ts @@ -1,3 +1,4 @@ +import { firecrawlHosting } from '@/tools/firecrawl/hosting' import type { SearchParams, SearchResponse } from '@/tools/firecrawl/types' import { SEARCH_RESULT_OUTPUT_PROPERTIES } from '@/tools/firecrawl/types' import type { ToolConfig } from '@/tools/types' @@ -23,33 +24,7 @@ export const searchTool: ToolConfig = { }, }, - hosting: { - envKeyPrefix: 'FIRECRAWL_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'firecrawl', - pricing: { - type: 'custom', - getCost: (_params, output) => { - if (output.creditsUsed == null) { - throw new Error('Firecrawl response missing creditsUsed field') - } - - const creditsUsed = Number(output.creditsUsed) - if (Number.isNaN(creditsUsed)) { - throw new Error('Firecrawl response returned a non-numeric creditsUsed field') - } - - return { - cost: creditsUsed * 0.001, - metadata: { creditsUsed }, - } - }, - }, - rateLimit: { - mode: 'per_request', - requestsPerMinute: 100, - }, - }, + hosting: firecrawlHosting(), request: { method: 'POST', diff --git a/apps/sim/tools/google_maps/distance_matrix.ts b/apps/sim/tools/google_maps/distance_matrix.ts index 19f981e8f4d..9b3b3f23f97 100644 --- a/apps/sim/tools/google_maps/distance_matrix.ts +++ b/apps/sim/tools/google_maps/distance_matrix.ts @@ -4,6 +4,14 @@ import type { } from '@/tools/google_maps/types' import type { ToolConfig } from '@/tools/types' +/** + * Google bills Distance Matrix per element (origins × destinations), not per + * request, at $5 per 1,000 elements. + * + * Source: https://developers.google.com/maps/billing-and-pricing/pricing#distance-matrix + */ +const DISTANCE_MATRIX_ELEMENT_USD = 0.005 + export const googleMapsDistanceMatrixTool: ToolConfig< GoogleMapsDistanceMatrixParams, GoogleMapsDistanceMatrixResponse @@ -63,8 +71,15 @@ export const googleMapsDistanceMatrixTool: ToolConfig< apiKeyParam: 'apiKey', byokProviderId: 'google_cloud', pricing: { - type: 'per_request', - cost: 0.005, + type: 'custom', + getCost: (_params, output) => { + const rows = (output.rows as Array<{ elements?: unknown[] }> | undefined) ?? [] + const elements = rows.reduce((total, row) => total + (row.elements?.length ?? 0), 0) + return { + cost: elements * DISTANCE_MATRIX_ELEMENT_USD, + metadata: { elements }, + } + }, }, rateLimit: { mode: 'per_request', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index c4fb4775447..62af30c8e1a 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -580,24 +580,37 @@ interface ToolCostResult { metadata?: Record } +/** + * Rejects a cost that cannot be billed. `NaN` would silently vanish from every + * downstream sum and `Infinity` would poison the ledger, so a pricing bug must + * surface as a metering failure instead of a corrupt charge. + */ +function assertBillableCost(cost: unknown, toolId: string): number { + if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0) { + throw new Error(`Hosted-key pricing for ${toolId} produced an unusable cost: ${String(cost)}`) + } + return cost +} + /** * Calculate cost based on pricing model */ function calculateToolCost( pricing: ToolHostingPricing, params: Record, - response: Record + response: Record, + toolId: string ): ToolCostResult { switch (pricing.type) { case 'per_request': - return { cost: pricing.cost } + return { cost: assertBillableCost(pricing.cost, toolId) } case 'custom': { const result = pricing.getCost(params, response) if (typeof result === 'number') { - return { cost: result } + return { cost: assertBillableCost(result, toolId) } } - return result + return { ...result, cost: assertBillableCost(result.cost, toolId) } } default: { @@ -627,7 +640,7 @@ async function processHostedKeyCost( return { cost: 0 } } - const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response) + const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response, tool.id) if (cost <= 0) return { cost: 0 } @@ -730,16 +743,31 @@ async function applyHostedKeyCostToResult( ): Promise { await reportCustomDimensionUsage(tool, params, finalResult.output, executionContext, requestId) - const { cost: hostedKeyCost, metadata } = await processHostedKeyCost( - tool, - params, - finalResult.output, - executionContext, - requestId - ) - const provider = tool.hosting?.byokProviderId || tool.id const key = envVarName ?? 'unknown' + + let hostedKeyCost = 0 + let metadata: Record | undefined + + try { + ;({ cost: hostedKeyCost, metadata } = await processHostedKeyCost( + tool, + params, + finalResult.output, + executionContext, + requestId + )) + } catch (error) { + // The provider already ran and already charged Sim's key. Failing the + // execution here would destroy the caller's result without recovering the + // spend, so the run stands and the gap is raised for reconciliation. + logger.error( + `[${requestId}] Hosted-key metering failed for ${tool.id}; execution succeeded unbilled`, + { provider, error: getErrorMessage(error) } + ) + hostedKeyMetrics.recordFailed({ provider, tool: tool.id, key, reason: 'metering' }) + } + hostedKeyMetrics.recordUsed({ provider, tool: tool.id, key }) hostedKeyMetrics.recordCostCharged(hostedKeyCost, { provider, tool: tool.id }) diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index a1d9b3f76a4..e06daade15f 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { type ModelCost, resolveProxiedModelCost } from '@/providers/cost-policy' import { getProviderFromModel } from '@/providers/utils' import type { ToolConfig, ToolResponse } from '@/tools/types' @@ -34,6 +35,7 @@ interface LLMChatResponse extends ToolResponse { completion?: number total?: number } + cost?: ModelCost } } @@ -175,6 +177,10 @@ export const llmChatTool: ToolConfig = { content: data.content, model: data.model, tokens: data.tokens, + // The provider proxy already applied the billing policy. Dropping its + // cost here would leave blocks built on this tool reporting tokens + // with no charge. + cost: resolveProxiedModelCost(data.cost), }, } }, @@ -183,5 +189,6 @@ export const llmChatTool: ToolConfig = { content: { type: 'string', description: 'The generated response content' }, model: { type: 'string', description: 'The model used for generation' }, tokens: { type: 'object', description: 'Token usage information' }, + cost: { type: 'object', description: 'Model cost for this call in dollars' }, }, } diff --git a/apps/sim/tools/parallel/deep_research.ts b/apps/sim/tools/parallel/deep_research.ts index cd7dac4b9eb..628d72a0c2b 100644 --- a/apps/sim/tools/parallel/deep_research.ts +++ b/apps/sim/tools/parallel/deep_research.ts @@ -6,6 +6,42 @@ import type { ToolConfig, ToolResponse } from '@/tools/types' const logger = createLogger('ParallelDeepResearchTool') +/** + * Dollar cost of one Parallel Task run per processor tier. + * + * Fast variants are priced identically to their standard counterparts, so both + * spellings are listed — the block exposes `pro-fast` and `ultra-fast`, and an + * unlisted tier would silently fall back to the cheapest rate. + * + * Source: https://docs.parallel.ai/getting-started/pricing + */ +const PROCESSOR_COST_USD: Record = { + lite: 0.005, + 'lite-fast': 0.005, + base: 0.01, + 'base-fast': 0.01, + core: 0.025, + 'core-fast': 0.025, + core2x: 0.05, + 'core2x-fast': 0.05, + pro: 0.1, + 'pro-fast': 0.1, + ultra: 0.3, + 'ultra-fast': 0.3, + ultra2x: 0.6, + 'ultra2x-fast': 0.6, + ultra4x: 1.2, + 'ultra4x-fast': 1.2, + ultra8x: 2.4, + 'ultra8x-fast': 2.4, +} + +/** + * Tier used when the caller does not pick one. Shared by the request body and + * the cost calculation so the tier Sim asks for is always the tier it charges. + */ +const DEFAULT_PROCESSOR = 'pro' + export const deepResearchTool: ToolConfig = { id: 'parallel_deep_research', name: 'Parallel AI Deep Research', @@ -20,34 +56,21 @@ export const deepResearchTool: ToolConfig { - // Parallel Task API: cost varies by processor - // https://docs.parallel.ai/resources/pricing - const processorCosts: Record = { - lite: 0.005, - base: 0.01, - core: 0.025, - core2x: 0.05, - pro: 0.1, - ultra: 0.3, - ultra2x: 0.6, - ultra4x: 1.2, - ultra8x: 2.4, - } - const processor = (params.processor as string) || 'base' - const DEFAULT_PROCESSOR_COST = processorCosts.base - const knownCost = processorCosts[processor] + const processor = (params.processor as string) || DEFAULT_PROCESSOR + const fallbackCost = PROCESSOR_COST_USD[DEFAULT_PROCESSOR] + const knownCost = PROCESSOR_COST_USD[processor] if (knownCost == null) { logger.warn( - `Unknown Parallel processor "${processor}", using default processor cost $${DEFAULT_PROCESSOR_COST}` + `Unknown Parallel processor "${processor}", using default processor cost $${fallbackCost}` ) PlatformEvents.hostedKeyUnknownModelCost({ toolId: 'parallel_deep_research', modelName: processor, - defaultCost: DEFAULT_PROCESSOR_COST, + defaultCost: fallbackCost, }) } - const cost = knownCost ?? DEFAULT_PROCESSOR_COST - return { cost, metadata: { processor, defaultProcessorCost: DEFAULT_PROCESSOR_COST } } + const cost = knownCost ?? fallbackCost + return { cost, metadata: { processor, defaultProcessorCost: fallbackCost } } }, }, rateLimit: { @@ -67,7 +90,7 @@ export const deepResearchTool: ToolConfig { const body: Record = { input: params.input, - processor: params.processor || 'pro', + processor: params.processor || DEFAULT_PROCESSOR, task_spec: { output_schema: 'auto', }, diff --git a/packages/db/migrations/0269_late_spencer_smythe.sql b/packages/db/migrations/0269_late_spencer_smythe.sql new file mode 100644 index 00000000000..a763e805791 --- /dev/null +++ b/packages/db/migrations/0269_late_spencer_smythe.sql @@ -0,0 +1 @@ +ALTER TABLE "chat" ADD COLUMN "include_tool_calls" boolean; \ No newline at end of file diff --git a/packages/db/migrations/meta/0269_snapshot.json b/packages/db/migrations/meta/0269_snapshot.json new file mode 100644 index 00000000000..c0dd5014352 --- /dev/null +++ b/packages/db/migrations/meta/0269_snapshot.json @@ -0,0 +1,17692 @@ +{ + "id": "dea711e3-b8c7-4279-bb8a-353bdaffdfe8", + "prevId": "e341f9c4-ac9a-4386-b6a6-4dfb31b952dd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index c9e4db84853..cde0fe20a26 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1877,6 +1877,13 @@ "when": 1784861741441, "tag": "0268_sso_domain_verification", "breakpoints": true + }, + { + "idx": 269, + "version": "7", + "when": 1784911198876, + "tag": "0269_late_spencer_smythe", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5f67654c9fd..a875f02059c 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1158,11 +1158,19 @@ export const chat = pgTable( outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects /** - * When true, public chat SSE may expose provider thinking/tool events if the + * When true, public chat SSE may expose provider thinking events if the * client also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. * Default off — never derived from auth type or isSecureMode. */ includeThinking: boolean('include_thinking').notNull().default(false), + /** + * When true, public chat SSE may expose tool lifecycle events if the client + * also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. + * + * Null preserves the pre-expand policy: readers fall back to includeThinking. + */ + // contract-pending(after the includeToolCalls expand release is fully deployed): backfill include_tool_calls from include_thinking, then set DEFAULT false and NOT NULL — all new-app chat writes persist an explicit value + includeToolCalls: boolean('include_tool_calls'), archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 7adad24619b..504c7bf3a94 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -449,6 +449,7 @@ export const schemaMock = { allowedEmails: 'allowedEmails', outputConfigs: 'outputConfigs', includeThinking: 'includeThinking', + includeToolCalls: 'includeToolCalls', archivedAt: 'archivedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', diff --git a/scripts/sync-agent-stream-docs.ts b/scripts/sync-agent-stream-docs.ts index 4936ade9dda..db11482dd99 100644 --- a/scripts/sync-agent-stream-docs.ts +++ b/scripts/sync-agent-stream-docs.ts @@ -121,7 +121,7 @@ function buildGeneratedContent(): { content: string; errors: string[] } { const lines: string[] = [] lines.push('') lines.push( - `Live tool-call chips stream for **${liveToolProviders.join(', ')}** models. Other providers run tools without live chips — tool results still appear in the block output when the run completes.` + `Live tool-call chips stream for **${liveToolProviders.join(', ')}** 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.` ) lines.push('') lines.push('| Provider | Streamed thinking | Models |')