Skip to content

Commit f6281f9

Browse files
author
Theodore Li
committed
Merge branch 'feat/hosted-key-agent' into feat/migrate-byok
2 parents 62219a6 + a463ebc commit f6281f9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+1217
-37
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
---
2+
name: add-hosted-key
3+
description: Add hosted API key support to a tool so Sim provides the key when users don't bring their own. Use when adding hosted keys, BYOK support, hideWhenHosted, or hosted key pricing to a tool or block.
4+
---
5+
6+
# Adding Hosted Key Support to a Tool
7+
8+
When a tool has hosted key support, Sim provides its own API key if the user hasn't configured one (via BYOK or env var). Usage is metered and billed to the workspace.
9+
10+
## Overview
11+
12+
| Step | What | Where |
13+
|------|------|-------|
14+
| 1 | Register BYOK provider ID | `tools/types.ts`, `app/api/workspaces/[id]/byok-keys/route.ts` |
15+
| 2 | Research the API's pricing and rate limits | API docs / pricing page (before writing any code) |
16+
| 3 | Add `hosting` config to the tool | `tools/{service}/{action}.ts` |
17+
| 4 | Hide API key field when hosted | `blocks/blocks/{service}.ts` |
18+
| 5 | Add to BYOK settings UI | BYOK settings component (`byok.tsx`) |
19+
| 6 | Summarize pricing and throttling comparison | Output to user (after all code changes) |
20+
21+
## Step 1: Register the BYOK Provider ID
22+
23+
Add the new provider to the `BYOKProviderId` union in `tools/types.ts`:
24+
25+
```typescript
26+
export type BYOKProviderId =
27+
| 'openai'
28+
| 'anthropic'
29+
// ...existing providers
30+
| 'your_service'
31+
```
32+
33+
Then add it to `VALID_PROVIDERS` in `app/api/workspaces/[id]/byok-keys/route.ts`:
34+
35+
```typescript
36+
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'your_service'] as const
37+
```
38+
39+
## Step 2: Research the API's Pricing Model and Rate Limits
40+
41+
**Before writing any `getCost` or `rateLimit` code**, look up the service's official documentation for both pricing and rate limits. You need to understand:
42+
43+
### Pricing
44+
45+
1. **How the API charges** — per request, per credit, per token, per step, per minute, etc.
46+
2. **Whether the API reports cost in its response** — look for fields like `creditsUsed`, `costDollars`, `tokensUsed`, or similar in the response body or headers
47+
3. **Whether cost varies by endpoint/options** — some APIs charge more for certain features (e.g., Firecrawl charges 1 credit/page base but +4 for JSON format, +4 for enhanced mode)
48+
4. **The dollar-per-unit rate** — what each credit/token/unit costs in dollars on our plan
49+
50+
### Rate Limits
51+
52+
1. **What rate limits the API enforces** — requests per minute/second, tokens per minute, concurrent requests, etc.
53+
2. **Whether limits vary by plan tier** — free vs paid vs enterprise often have different ceilings
54+
3. **Whether limits are per-key or per-account** — determines whether adding more hosted keys actually increases total throughput
55+
4. **What the API returns when rate limited** — HTTP 429, `Retry-After` header, error body format, etc.
56+
5. **Whether there are multiple dimensions** — some APIs limit both requests/min AND tokens/min independently
57+
58+
Search the API's docs/pricing page (use WebSearch/WebFetch). Capture the pricing model as a comment in `getCost` so future maintainers know the source of truth.
59+
60+
### Setting Our Rate Limits
61+
62+
Our rate limiter (`lib/core/rate-limiter/hosted-key/`) uses a token-bucket algorithm applied **per billing actor** (workspace). It supports two modes:
63+
64+
- **`per_request`** — simple; just `requestsPerMinute`. Good when the API charges flat per-request or cost doesn't vary much.
65+
- **`custom`**`requestsPerMinute` plus additional `dimensions` (e.g., `tokens`, `search_units`). Each dimension has its own `limitPerMinute` and an `extractUsage` function that reads actual usage from the response. Use when the API charges on a variable metric (tokens, credits) and you want to cap that metric too.
66+
67+
When choosing values for `requestsPerMinute` and any dimension limits:
68+
69+
- **Stay well below the API's per-key limit** — our keys are shared across all workspaces. If the API allows 60 RPM per key and we have 3 keys, the global ceiling is ~180 RPM. Set the per-workspace limit low enough (e.g., 20-60 RPM) that many workspaces can coexist without collectively hitting the API's ceiling.
70+
- **Account for key pooling** — our round-robin distributes requests across `N` hosted keys, so the effective API-side rate per key is `(total requests) / N`. But per-workspace limits are enforced *before* key selection, so they apply regardless of key count.
71+
- **Prefer conservative defaults** — it's easy to raise limits later but hard to claw back after users depend on high throughput.
72+
73+
## Step 3: Add `hosting` Config to the Tool
74+
75+
Add a `hosting` object to the tool's `ToolConfig`. This tells the execution layer how to acquire hosted keys, calculate cost, and rate-limit.
76+
77+
```typescript
78+
hosting: {
79+
envKeyPrefix: 'YOUR_SERVICE_API_KEY',
80+
apiKeyParam: 'apiKey',
81+
byokProviderId: 'your_service',
82+
pricing: {
83+
type: 'custom',
84+
getCost: (_params, output) => {
85+
if (output.creditsUsed == null) {
86+
throw new Error('Response missing creditsUsed field')
87+
}
88+
const creditsUsed = output.creditsUsed as number
89+
const cost = creditsUsed * 0.001 // dollars per credit
90+
return { cost, metadata: { creditsUsed } }
91+
},
92+
},
93+
rateLimit: {
94+
mode: 'per_request',
95+
requestsPerMinute: 100,
96+
},
97+
},
98+
```
99+
100+
### Hosted Key Env Var Convention
101+
102+
Keys use a numbered naming pattern driven by a count env var:
103+
104+
```
105+
YOUR_SERVICE_API_KEY_COUNT=3
106+
YOUR_SERVICE_API_KEY_1=sk-...
107+
YOUR_SERVICE_API_KEY_2=sk-...
108+
YOUR_SERVICE_API_KEY_3=sk-...
109+
```
110+
111+
The `envKeyPrefix` value (`YOUR_SERVICE_API_KEY`) determines which env vars are read at runtime. Adding more keys only requires bumping the count and adding the new env var.
112+
113+
### Pricing: Prefer API-Reported Cost
114+
115+
Always prefer using cost data returned by the API (e.g., `creditsUsed`, `costDollars`). This is the most accurate because it accounts for variable pricing tiers, feature modifiers, and plan-level discounts.
116+
117+
**When the API reports cost** — use it directly and throw if missing:
118+
119+
```typescript
120+
pricing: {
121+
type: 'custom',
122+
getCost: (params, output) => {
123+
if (output.creditsUsed == null) {
124+
throw new Error('Response missing creditsUsed field')
125+
}
126+
// $0.001 per credit — from https://example.com/pricing
127+
const cost = (output.creditsUsed as number) * 0.001
128+
return { cost, metadata: { creditsUsed: output.creditsUsed } }
129+
},
130+
},
131+
```
132+
133+
**When the API does NOT report cost** — compute it from params/output based on the pricing docs, but still validate the data you depend on:
134+
135+
```typescript
136+
pricing: {
137+
type: 'custom',
138+
getCost: (params, output) => {
139+
if (!Array.isArray(output.searchResults)) {
140+
throw new Error('Response missing searchResults, cannot determine cost')
141+
}
142+
// Serper: 1 credit for <=10 results, 2 credits for >10 — from https://serper.dev/pricing
143+
const credits = Number(params.num) > 10 ? 2 : 1
144+
return { cost: credits * 0.001, metadata: { credits } }
145+
},
146+
},
147+
```
148+
149+
**`getCost` must always throw** if it cannot determine cost. Never silently fall back to a default — this would hide billing inaccuracies.
150+
151+
### Capturing Cost Data from the API
152+
153+
If the API returns cost info, capture it in `transformResponse` so `getCost` can read it from the output:
154+
155+
```typescript
156+
transformResponse: async (response: Response) => {
157+
const data = await response.json()
158+
return {
159+
success: true,
160+
output: {
161+
results: data.results,
162+
creditsUsed: data.creditsUsed, // pass through for getCost
163+
},
164+
}
165+
},
166+
```
167+
168+
For async/polling tools, capture it in `postProcess` when the job completes:
169+
170+
```typescript
171+
if (jobData.status === 'completed') {
172+
result.output = {
173+
data: jobData.data,
174+
creditsUsed: jobData.creditsUsed,
175+
}
176+
}
177+
```
178+
179+
## Step 4: Hide the API Key Field When Hosted
180+
181+
In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` to the API key subblock. This hides the field on hosted Sim since the platform provides the key:
182+
183+
```typescript
184+
{
185+
id: 'apiKey',
186+
title: 'API Key',
187+
type: 'short-input',
188+
placeholder: 'Enter your API key',
189+
password: true,
190+
required: true,
191+
hideWhenHosted: true,
192+
},
193+
```
194+
195+
The visibility is controlled by `isSubBlockHiddenByHostedKey()` in `lib/workflows/subblocks/visibility.ts`, which checks the `isHosted` feature flag.
196+
197+
## Step 5: Add to the BYOK Settings UI
198+
199+
Add an entry to the `PROVIDERS` array in the BYOK settings component so users can bring their own key. You need the service icon from `components/icons.tsx`:
200+
201+
```typescript
202+
{
203+
id: 'your_service',
204+
name: 'Your Service',
205+
icon: YourServiceIcon,
206+
description: 'What this service does',
207+
placeholder: 'Enter your API key',
208+
},
209+
```
210+
211+
## Step 6: Summarize Pricing and Throttling Comparison
212+
213+
After all code changes are complete, output a detailed summary to the user covering:
214+
215+
### What to include
216+
217+
1. **API's pricing model** — how the service charges (per token, per credit, per request, etc.), the specific rates found in docs, and whether the API reports cost in responses.
218+
2. **Our `getCost` approach** — how we calculate cost, what fields we depend on, and any assumptions or estimates (especially when the API doesn't report exact dollar cost).
219+
3. **API's rate limits** — the documented limits (RPM, TPM, concurrent, etc.), which plan tier they apply to, and whether they're per-key or per-account.
220+
4. **Our `rateLimit` config** — what we set for `requestsPerMinute` (and dimensions if custom mode), why we chose those values, and how they compare to the API's limits.
221+
5. **Key pooling impact** — how many hosted keys we expect, and how round-robin distribution affects the effective per-key rate at the API.
222+
6. **Gaps or risks** — anything the API charges for that we don't meter, rate limit dimensions we chose not to enforce, or pricing that may be inaccurate due to variable model/tier costs.
223+
224+
### Format
225+
226+
Present this as a structured summary with clear headings. Example:
227+
228+
```
229+
### Pricing
230+
- **API charges**: $X per 1M tokens (input), $Y per 1M tokens (output) — varies by model
231+
- **Response reports cost?**: No — only token counts in `usage` field
232+
- **Our getCost**: Estimates cost at $Z per 1M total tokens based on median model pricing
233+
- **Risk**: Actual cost varies by model; our estimate may over/undercharge for cheap/expensive models
234+
235+
### Throttling
236+
- **API limits**: 300 RPM per key (paid tier), 60 RPM (free tier)
237+
- **Per-key or per-account**: Per key — more keys = more throughput
238+
- **Our config**: 60 RPM per workspace (per_request mode)
239+
- **With N keys**: Effective per-key rate is (total RPM across workspaces) / N
240+
- **Headroom**: Comfortable — even 10 active workspaces at full rate = 600 RPM / 3 keys = 200 RPM per key, under the 300 RPM API limit
241+
```
242+
243+
This summary helps reviewers verify that the pricing and rate limiting are well-calibrated and surfaces any risks that need monitoring.
244+
245+
## Checklist
246+
247+
- [ ] Provider added to `BYOKProviderId` in `tools/types.ts`
248+
- [ ] Provider added to `VALID_PROVIDERS` in the BYOK keys API route
249+
- [ ] API pricing docs researched — understand per-unit cost and whether the API reports cost in responses
250+
- [ ] API rate limits researched — understand RPM/TPM limits, per-key vs per-account, and plan tiers
251+
- [ ] `hosting` config added to the tool with `envKeyPrefix`, `apiKeyParam`, `byokProviderId`, `pricing`, and `rateLimit`
252+
- [ ] `getCost` throws if required cost data is missing from the response
253+
- [ ] Cost data captured in `transformResponse` or `postProcess` if API provides it
254+
- [ ] `hideWhenHosted: true` added to the API key subblock in the block config
255+
- [ ] Provider entry added to the BYOK settings UI with icon and description
256+
- [ ] Env vars documented: `{PREFIX}_COUNT` and `{PREFIX}_1..N`
257+
- [ ] Pricing and throttling summary provided to reviewer

apps/sim/app/api/files/utils.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { existsSync } from 'fs'
2-
import { join, resolve, sep } from 'path'
2+
import path from 'path'
33
import { createLogger } from '@sim/logger'
44
import { NextResponse } from 'next/server'
55
import { UPLOAD_DIR } from '@/lib/uploads/config'
@@ -155,7 +155,7 @@ function sanitizeFilename(filename: string): string {
155155
return sanitized
156156
})
157157

158-
return sanitizedSegments.join(sep)
158+
return sanitizedSegments.join(path.sep)
159159
}
160160

161161
export function findLocalFile(filename: string): string | null {
@@ -168,17 +168,18 @@ export function findLocalFile(filename: string): string | null {
168168
}
169169

170170
const possiblePaths = [
171-
join(UPLOAD_DIR, sanitizedFilename),
172-
join(process.cwd(), 'uploads', sanitizedFilename),
171+
path.join(UPLOAD_DIR, sanitizedFilename),
172+
path.join(process.cwd(), 'uploads', sanitizedFilename),
173173
]
174174

175-
for (const path of possiblePaths) {
176-
const resolvedPath = resolve(path)
177-
const allowedDirs = [resolve(UPLOAD_DIR), resolve(process.cwd(), 'uploads')]
175+
for (const filePath of possiblePaths) {
176+
const resolvedPath = path.resolve(filePath)
177+
const allowedDirs = [path.resolve(UPLOAD_DIR), path.resolve(process.cwd(), 'uploads')]
178178

179179
// Must be within allowed directory but NOT the directory itself
180180
const isWithinAllowedDir = allowedDirs.some(
181-
(allowedDir) => resolvedPath.startsWith(allowedDir + sep) && resolvedPath !== allowedDir
181+
(allowedDir) =>
182+
resolvedPath.startsWith(allowedDir + path.sep) && resolvedPath !== allowedDir
182183
)
183184

184185
if (!isWithinAllowedDir) {

apps/sim/app/api/workspaces/[id]/byok-keys/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,22 @@ import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/per
1313

1414
const logger = createLogger('WorkspaceBYOKKeysAPI')
1515

16-
const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'exa'] as const
16+
const VALID_PROVIDERS = [
17+
'openai',
18+
'anthropic',
19+
'google',
20+
'mistral',
21+
'exa',
22+
'browser_use',
23+
'serper',
24+
'firecrawl',
25+
'huggingface',
26+
'linkup',
27+
'perplexity',
28+
'jina',
29+
'google_cloud',
30+
'elevenlabs',
31+
] as const
1732

1833
const UpsertKeySchema = z.object({
1934
providerId: z.enum(VALID_PROVIDERS),

0 commit comments

Comments
 (0)