Skip to content

Commit e56d567

Browse files
committed
chore(byok): audit that every hosted provider is fully wired
Adds check:byok-providers. A hosted tool names its provider once in hosting.byokProviderId, but the id must also reach the zod enum, the settings PROVIDERS row, and a PROVIDER_SECTIONS section. Only the union is compiler- enforced; the rest fail silently, which is how TinyFish shipped with no settings row in the first place. Also flags drift between the two BYOKProviderId declarations. Correct the Search and Fetch rate-limit rationale: TinyFish documents both ceilings per API key, not per account, so the numbered key pool does raise the total. Records why a free product is still worth hosting — the endpoints require a key, so hosting is what removes the signup.
1 parent b10c356 commit e56d567

5 files changed

Lines changed: 230 additions & 39 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.test.tsx

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -171,27 +171,7 @@ vi.mock('@/hooks/queries/byok-keys', () => ({
171171
useDeleteOrganizationBYOKKey: mocks.mutation,
172172
}))
173173

174-
import {
175-
BYOK,
176-
PROVIDER_SECTIONS,
177-
PROVIDERS,
178-
} from '@/app/workspace/[workspaceId]/settings/components/byok/byok'
179-
180-
describe('BYOK provider sections', () => {
181-
it('renders every provider, since the sectioned list drops unlisted ids', () => {
182-
const sectioned = new Set(PROVIDER_SECTIONS.flatMap((section) => section.ids))
183-
const missing = PROVIDERS.map((provider) => provider.id).filter((id) => !sectioned.has(id))
184-
expect(missing).toEqual([])
185-
})
186-
187-
it('has no section entry without a matching provider', () => {
188-
const known = new Set(PROVIDERS.map((provider) => provider.id))
189-
const orphaned = PROVIDER_SECTIONS.flatMap((section) => section.ids).filter(
190-
(id) => !known.has(id)
191-
)
192-
expect(orphaned).toEqual([])
193-
})
194-
})
174+
import { BYOK } from '@/app/workspace/[workspaceId]/settings/components/byok/byok'
195175

196176
describe('BYOK scope access', () => {
197177
let container: HTMLDivElement

apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,7 @@ import {
7171
useUpsertOrganizationBYOKKey,
7272
} from '@/hooks/queries/byok-keys'
7373

74-
/**
75-
* Every provider the BYOK page can render. A provider listed here but missing
76-
* from {@link PROVIDER_SECTIONS} is dropped by the sectioned renderer, so the
77-
* two lists are kept in sync by a test rather than by memory.
78-
*/
79-
export const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [
74+
const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [
8075
{
8176
id: 'openai',
8277
name: 'OpenAI',
@@ -343,7 +338,7 @@ export const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [
343338
* {@link PROVIDERS} belongs to exactly one section; rows keep their
344339
* {@link PROVIDERS} order within each group.
345340
*/
346-
export const PROVIDER_SECTIONS: BYOKProviderSection[] = [
341+
const PROVIDER_SECTIONS: BYOKProviderSection[] = [
347342
{
348343
label: 'Models',
349344
ids: [

apps/sim/tools/tinyfish/hosting.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@ export function tinyfishAgentHosting<P>(): ToolHostingConfig<P> {
6868
/**
6969
* Hosting config for the Search API.
7070
*
71-
* Search never draws on the TinyFish wallet, so the hosted key costs nothing to
72-
* run. The documented ceiling is 30 requests/minute per account, so a key pool
73-
* only raises the total when each key belongs to a separate TinyFish account.
74-
* Each workspace therefore gets a third of one account's budget.
71+
* Search is free at any wallet balance, so the hosted key costs nothing to run.
72+
* It is still worth hosting: free is not unauthenticated — the endpoint requires
73+
* an `X-API-Key`, so without a hosted key a user would have to create a TinyFish
74+
* account before they could search at all.
7575
*
76-
* Source: https://www.tinyfish.ai/pricing
76+
* Because nothing is billed, the rate limit is the only backpressure. TinyFish
77+
* enforces 30 requests/minute **per API key**, so the numbered key pool raises
78+
* the ceiling proportionally, and this per-workspace share lets roughly three
79+
* workspaces run flat out against a single key.
80+
*
81+
* Source: https://docs.tinyfish.ai/search-api/reference
7782
*/
7883
export function tinyfishSearchHosting<P>(): ToolHostingConfig<P> {
7984
return {
@@ -94,17 +99,21 @@ export function tinyfishSearchHosting<P>(): ToolHostingConfig<P> {
9499
/**
95100
* Hosting config for the Fetch API.
96101
*
97-
* Fetch is free but its documented ceiling is measured in URLs (150/minute), not
102+
* Fetch is free at any wallet balance and is hosted for the same reason as Search:
103+
* the endpoint still requires an API key, so hosting is what makes it work without
104+
* the user holding a TinyFish account.
105+
*
106+
* Its documented ceiling is measured in URLs (150/minute **per API key**), not in
98107
* requests, and one request carries up to 10 URLs. The URL count is therefore
99108
* tracked as its own dimension so a workspace batching 10 URLs per call is
100109
* throttled on the same axis TinyFish enforces. Usage is read from the submitted
101-
* list rather than the returned arrays, because TinyFish counts a URL it could
102-
* not fetch and the response would undercount one that landed in neither array.
110+
* list rather than the returned arrays, because TinyFish counts a URL it could not
111+
* fetch and the response would undercount one that landed in neither array.
103112
*
104-
* The 40/minute per-workspace share of the 150/minute account ceiling leaves room
105-
* for roughly three workspaces to run flat out on one key.
113+
* The 40/minute per-workspace share leaves room for roughly three workspaces to
114+
* run flat out against a single key.
106115
*
107-
* Source: https://www.tinyfish.ai/pricing
116+
* Source: https://docs.tinyfish.ai/fetch-api/reference
108117
*/
109118
export function tinyfishFetchHosting<P>(): ToolHostingConfig<P> {
110119
return {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"check:utils": "bun run scripts/check-utils-enforcement.ts",
5555
"check:canvas-sentences": "bun run apps/sim/scripts/check-canvas-sentences.ts --require-coverage",
5656
"check:bare-icons": "bun run scripts/check-bare-icons.ts",
57+
"check:byok-providers": "bun run scripts/check-byok-providers.ts",
5758
"check:icon-paths": "bun run scripts/check-icon-paths.ts",
5859
"check:icon-path-precision": "bun run scripts/check-icon-path-precision.ts",
5960
"check:migrations": "bun run scripts/check-migrations-safety.ts",

scripts/check-byok-providers.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Audits that every BYOK provider is wired through all four places it must appear.
4+
*
5+
* A hosted tool names its provider once, in `hosting.byokProviderId`, but that id
6+
* has to be registered in three other files before the feature actually works:
7+
*
8+
* tools/types.ts the `BYOKProviderId` union tools compile against
9+
* lib/api/contracts/byok-keys.ts the zod enum the byok-keys route validates against
10+
* settings/.../byok.tsx `PROVIDERS` the row the settings page renders
11+
* settings/.../byok.tsx `SECTIONS` the section that row is grouped under
12+
*
13+
* Only the first is enforced by the compiler. The other three fail *silently*:
14+
*
15+
* - Missing from `PROVIDERS`, the settings page has no row, so a workspace can
16+
* never bring its own key and is stuck on the hosted key.
17+
* - Missing from `PROVIDER_SECTIONS`, the row exists but the sectioned renderer
18+
* (`byok-key-manager.tsx` filters `providers` by `section.ids.includes(p.id)`)
19+
* drops it, so the page looks correct in source and renders nothing.
20+
* - Drifted between the two `BYOKProviderId` declarations, a tool can name a
21+
* provider the route then rejects at runtime.
22+
*
23+
* None of those produce a type error, a test failure, or a log line — which is
24+
* exactly why they need an audit rather than a convention.
25+
*
26+
* Run: `bun run check:byok-providers`
27+
*/
28+
import { readFile } from 'node:fs/promises'
29+
import { dirname, resolve } from 'node:path'
30+
import { fileURLToPath } from 'node:url'
31+
import { tools } from '../apps/sim/tools/registry'
32+
33+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
34+
const ROOT = resolve(SCRIPT_DIR, '..')
35+
const APP = resolve(ROOT, 'apps/sim')
36+
const TOOL_TYPES = resolve(APP, 'tools/types.ts')
37+
const CONTRACT = resolve(APP, 'lib/api/contracts/byok-keys.ts')
38+
const SETTINGS = resolve(APP, 'app/workspace/[workspaceId]/settings/components/byok/byok.tsx')
39+
40+
/** Path as written in an error message, relative to the repo root. */
41+
function rel(absolute: string): string {
42+
return absolute.slice(ROOT.length + 1)
43+
}
44+
45+
/**
46+
* Returns the source between the brackets opened by the first match of `start`.
47+
*
48+
* Bracket-counting rather than a lazy regex: every one of these blocks nests
49+
* (an object per provider, an array per section), so `[\s\S]*?\]` would stop at
50+
* the first inner close.
51+
*/
52+
function blockAfter(source: string, start: RegExp, open: '[' | '{'): string {
53+
const match = source.match(start)
54+
if (match?.index === undefined) {
55+
throw new Error(`could not locate ${start} — has the declaration been renamed?`)
56+
}
57+
const close = open === '[' ? ']' : '}'
58+
const from = source.indexOf(open, match.index + match[0].length - 1)
59+
if (from === -1) throw new Error(`no ${open} after ${start}`)
60+
61+
let depth = 0
62+
for (let i = from; i < source.length; i++) {
63+
if (source[i] === open) depth++
64+
else if (source[i] === close) {
65+
depth--
66+
if (depth === 0) return source.slice(from + 1, i)
67+
}
68+
}
69+
throw new Error(`unbalanced ${open} after ${start}`)
70+
}
71+
72+
/** Every single-quoted string literal in a chunk of source, in order. */
73+
function quoted(source: string): string[] {
74+
return [...source.matchAll(/'([a-z0-9_-]+)'/gi)].map((m) => m[1])
75+
}
76+
77+
interface Failure {
78+
file: string
79+
message: string
80+
items: string[]
81+
fix: string
82+
}
83+
84+
async function main() {
85+
const [toolTypesSrc, contractSrc, settingsSrc] = await Promise.all([
86+
readFile(TOOL_TYPES, 'utf8'),
87+
readFile(CONTRACT, 'utf8'),
88+
readFile(SETTINGS, 'utf8'),
89+
])
90+
91+
const unionDecl = toolTypesSrc.match(/export type BYOKProviderId =([\s\S]*?)\n\n/)
92+
if (!unionDecl) throw new Error(`could not locate BYOKProviderId union in ${rel(TOOL_TYPES)}`)
93+
const union = new Set(quoted(unionDecl[1]))
94+
95+
const schema = new Set(quoted(blockAfter(contractSrc, /byokProviderIdSchema = z\.enum\(/, '[')))
96+
97+
const settingsProviders = new Set(
98+
[
99+
...blockAfter(settingsSrc, /const PROVIDERS[^=]*=/, '[').matchAll(
100+
/\bid:\s*'([a-z0-9_-]+)'/gi
101+
),
102+
].map((m) => m[1])
103+
)
104+
105+
const sectioned = new Set(
106+
[
107+
...blockAfter(settingsSrc, /const PROVIDER_SECTIONS[^=]*=/, '[').matchAll(
108+
/\bids:\s*\[([\s\S]*?)\]/g
109+
),
110+
].flatMap((m) => quoted(m[1]))
111+
)
112+
113+
/** Provider id -> the hosted tools that name it. */
114+
const hostedBy = new Map<string, string[]>()
115+
for (const [toolId, tool] of Object.entries(tools)) {
116+
const provider = tool.hosting?.byokProviderId
117+
if (!provider) continue
118+
const existing = hostedBy.get(provider)
119+
if (existing) existing.push(toolId)
120+
else hostedBy.set(provider, [toolId])
121+
}
122+
123+
const failures: Failure[] = []
124+
const describe = (provider: string) => {
125+
const owners = hostedBy.get(provider) ?? []
126+
return owners.length > 0
127+
? `${provider} (${owners[0]}${owners.length > 1 ? ', …' : ''})`
128+
: provider
129+
}
130+
131+
const missingFromSchema = [...hostedBy.keys()].filter((p) => !schema.has(p)).sort()
132+
if (missingFromSchema.length > 0) {
133+
failures.push({
134+
file: rel(CONTRACT),
135+
message: 'hosted tools name a provider the byok-keys route would reject',
136+
items: missingFromSchema.map(describe),
137+
fix: 'add the id to byokProviderIdSchema',
138+
})
139+
}
140+
141+
const missingFromSettings = [...hostedBy.keys()]
142+
.filter((p) => schema.has(p) && !settingsProviders.has(p))
143+
.sort()
144+
if (missingFromSettings.length > 0) {
145+
failures.push({
146+
file: rel(SETTINGS),
147+
message:
148+
'hosted tools name a provider with no settings row, so a workspace cannot bring its own key',
149+
items: missingFromSettings.map(describe),
150+
fix: 'add an entry to PROVIDERS',
151+
})
152+
}
153+
154+
const unsectioned = [...settingsProviders].filter((p) => !sectioned.has(p)).sort()
155+
if (unsectioned.length > 0) {
156+
failures.push({
157+
file: rel(SETTINGS),
158+
message: 'PROVIDERS entries the sectioned renderer drops, so their row never appears',
159+
items: unsectioned,
160+
fix: 'add the id to the right PROVIDER_SECTIONS section',
161+
})
162+
}
163+
164+
const orphanedSections = [...sectioned].filter((p) => !settingsProviders.has(p)).sort()
165+
if (orphanedSections.length > 0) {
166+
failures.push({
167+
file: rel(SETTINGS),
168+
message: 'PROVIDER_SECTIONS lists ids with no matching PROVIDERS entry',
169+
items: orphanedSections,
170+
fix: 'remove the stale id, or add the missing PROVIDERS entry',
171+
})
172+
}
173+
174+
const unionOnly = [...union].filter((p) => !schema.has(p)).sort()
175+
const schemaOnly = [...schema].filter((p) => !union.has(p)).sort()
176+
if (unionOnly.length > 0 || schemaOnly.length > 0) {
177+
failures.push({
178+
file: `${rel(TOOL_TYPES)} vs ${rel(CONTRACT)}`,
179+
message: 'the two BYOKProviderId declarations have drifted',
180+
items: [
181+
...unionOnly.map((p) => `${p} (union only)`),
182+
...schemaOnly.map((p) => `${p} (zod enum only)`),
183+
],
184+
fix: 'keep the union and the zod enum listing the same ids',
185+
})
186+
}
187+
188+
if (failures.length > 0) {
189+
console.error('\n❌ BYOK provider wiring is incomplete\n')
190+
for (const failure of failures) {
191+
console.error(` ${failure.file}: ${failure.message}`)
192+
for (const item of failure.items) console.error(` - ${item}`)
193+
console.error(` fix: ${failure.fix}\n`)
194+
}
195+
process.exit(1)
196+
}
197+
198+
console.log(
199+
`✓ BYOK provider wiring is complete (${hostedBy.size} hosted providers, ${settingsProviders.size} settings rows)`
200+
)
201+
}
202+
203+
main().catch((error) => {
204+
console.error(`\n❌ check-byok-providers failed: ${error.message}`)
205+
process.exit(1)
206+
})

0 commit comments

Comments
 (0)