Skip to content

Commit 7987052

Browse files
committed
improvement(media): label fal auth failures with the refused key and use the shared env-flags mock
getFalApiKey now returns the env var its key came from, so a refused key in the numbered pool is named on the metric. The metrics module reads isHosted on each call instead of copying it at import, and the test sets it through the central env-flags mock under fake timers.
1 parent 7e7459b commit 7987052

7 files changed

Lines changed: 66 additions & 50 deletions

File tree

‎apps/sim/lib/media/falai-audio.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ function buildInput(
141141

142142
export async function generateFalAudio(params: GenerateFalAudioParams): Promise<GeneratedAudio> {
143143
const type: AudioType = params.type || 'speech'
144-
const apiKey = getFalApiKey()
144+
const { apiKey, envVarName } = getFalApiKey()
145145

146146
// Voice cloning: a reference sample routes to a zero-shot clone model (F5-TTS),
147147
// which conditions on the sample (ref_audio_url) and speaks the prompt in that voice.
@@ -156,7 +156,7 @@ export async function generateFalAudio(params: GenerateFalAudioParams): Promise<
156156
model,
157157
input,
158158
apiKey,
159-
'generate_audio',
159+
{ tool: 'generate_audio', envVarName },
160160
params.signal
161161
)
162162
const cost = await recordFalCost(apiKey, model, requestId, params.onCost)
@@ -182,7 +182,7 @@ export async function generateFalAudio(params: GenerateFalAudioParams): Promise<
182182
model,
183183
input,
184184
apiKey,
185-
'generate_audio',
185+
{ tool: 'generate_audio', envVarName },
186186
params.signal
187187
)
188188
const cost = await recordFalCost(apiKey, model, requestId, params.onCost)
Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { jsonResponse } from '@sim/testing/helpers/http'
2+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
23
import { inputValidationMock } from '@sim/testing/mocks/input-validation.mock'
3-
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
45

56
const { sentMetricData } = vi.hoisted(() => ({
67
sentMetricData: [] as Array<{
@@ -10,10 +11,6 @@ const { sentMetricData } = vi.hoisted(() => ({
1011
}))
1112

1213
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
13-
vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({
14-
...(await importOriginal<typeof import('@/lib/core/config/env-flags')>()),
15-
isHosted: true,
16-
}))
1714
vi.mock('@aws-sdk/client-cloudwatch', () => ({
1815
StandardUnit: { Count: 'Count', None: 'None', Milliseconds: 'Milliseconds' },
1916
PutMetricDataCommand: class {
@@ -36,41 +33,58 @@ function stubFalSubmit(status: number, body: unknown) {
3633
)
3734
}
3835

39-
async function failedHostedKeyReasons(): Promise<Array<Record<string, string | undefined>>> {
36+
async function failedHostedKeyDimensions(): Promise<Array<Record<string, string | undefined>>> {
4037
await flushHostedKeyMetrics()
4138
return sentMetricData
4239
.filter((datum) => datum.MetricName === 'Failed')
4340
.map((datum) => Object.fromEntries((datum.Dimensions ?? []).map((d) => [d.Name, d.Value])))
4441
}
4542

4643
describe('a fal submit the hosted account refuses', () => {
47-
afterEach(() => {
48-
vi.unstubAllGlobals()
44+
beforeEach(() => {
45+
vi.useFakeTimers()
46+
setEnvFlags({ isHosted: true })
4947
sentMetricData.length = 0
5048
})
5149

50+
afterEach(() => {
51+
vi.useRealTimers()
52+
resetEnvFlagsMock()
53+
})
54+
5255
it.each([
5356
[403, { detail: 'User is locked. Reason: Exhausted balance.' }],
5457
[401, { detail: 'Invalid API key' }],
55-
])('records a hosted-key auth failure for a %i', async (status, body) => {
58+
])('records a hosted-key auth failure against the refused key for a %i', async (status, body) => {
5659
stubFalSubmit(status, body)
5760

58-
await expect(runFalQueue('fal-ai/model', {}, 'key', 'generate_audio')).rejects.toThrow(
59-
`Fal.ai API error: ${status}`
60-
)
61+
await expect(
62+
runFalQueue('fal-ai/model', {}, 'key', {
63+
tool: 'generate_audio',
64+
envVarName: 'FALAI_API_KEY_2',
65+
})
66+
).rejects.toThrow(`Fal.ai API error: ${status}`)
6167

62-
expect(await failedHostedKeyReasons()).toEqual([
63-
expect.objectContaining({ Provider: 'falai', Tool: 'generate_audio', Reason: 'auth' }),
68+
expect(await failedHostedKeyDimensions()).toEqual([
69+
expect.objectContaining({
70+
Provider: 'falai',
71+
Tool: 'generate_audio',
72+
Key: 'FALAI_API_KEY_2',
73+
Reason: 'auth',
74+
}),
6475
])
6576
})
6677

6778
it('records nothing when fal rejects the request input', async () => {
6879
stubFalSubmit(422, { detail: [{ msg: 'field required' }] })
6980

70-
await expect(runFalQueue('fal-ai/model', {}, 'key', 'generate_video')).rejects.toThrow(
71-
'Fal.ai API error: 422'
72-
)
81+
await expect(
82+
runFalQueue('fal-ai/model', {}, 'key', {
83+
tool: 'generate_video',
84+
envVarName: 'FALAI_API_KEY_1',
85+
})
86+
).rejects.toThrow('Fal.ai API error: 422')
7387

74-
expect(await failedHostedKeyReasons()).toEqual([])
88+
expect(await failedHostedKeyDimensions()).toEqual([])
7589
})
7690
})

‎apps/sim/lib/media/falai-video.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export async function generateFalVideo(params: GenerateFalVideoParams): Promise<
148148
)
149149
}
150150

151-
const apiKey = getFalApiKey()
151+
const { apiKey, envVarName } = getFalApiKey()
152152

153153
let endpoint = config.endpoint
154154
const input: Record<string, unknown> = { prompt: params.prompt }
@@ -181,7 +181,7 @@ export async function generateFalVideo(params: GenerateFalVideoParams): Promise<
181181
endpoint,
182182
input,
183183
apiKey,
184-
'generate_video',
184+
{ tool: 'generate_video', envVarName },
185185
params.signal
186186
)
187187
const cost = await recordFalCost(apiKey, endpoint, requestId, params.onCost)

‎apps/sim/lib/media/falai.test.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { downloadFalMedia, type FalCostRecorder, runFalQueue } from '@/lib/media
1313
import { generateFalAudio } from '@/lib/media/falai-audio'
1414
import { generateFalVideo } from '@/lib/media/falai-video'
1515

16+
const FAL_KEY_USE = { tool: 'generate_video', envVarName: 'FALAI_API_KEY' } as const
1617
const CANCEL_URL = 'https://queue.fal.run/fal-ai/model/requests/req-1/cancel'
1718
const STATUS_URL = 'https://queue.fal.run/fal-ai/model/requests/req-1/status'
1819

@@ -71,7 +72,7 @@ describe('runFalQueue on a request that never finishes', () => {
7172
'fal-ai/model',
7273
{ prompt: 'p' },
7374
'key',
74-
'generate_video',
75+
FAL_KEY_USE,
7576
caller.signal
7677
)
7778
const outcome = generation.then(
@@ -97,7 +98,7 @@ describe('runFalQueue on a request that never finishes', () => {
9798
'fal-ai/model',
9899
{ prompt: 'p' },
99100
'key',
100-
'generate_video',
101+
FAL_KEY_USE,
101102
caller.signal
102103
)
103104
const outcome = generation.then(
@@ -116,7 +117,7 @@ describe('runFalQueue on a request that never finishes', () => {
116117

117118
it('fails and cancels after its own ceiling even when nothing aborts it', async () => {
118119
const fal = stubStuckFalQueue()
119-
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', 'generate_video')
120+
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', FAL_KEY_USE)
120121
const outcome = generation.then(
121122
() => 'resolved',
122123
(error: unknown) => String(error)
@@ -135,7 +136,7 @@ describe('runFalQueue on a request that never finishes', () => {
135136
'fal-ai/model',
136137
{ prompt: 'p' },
137138
'key',
138-
'generate_video',
139+
FAL_KEY_USE,
139140
caller.signal
140141
).catch(() => undefined)
141142
await vi.advanceTimersByTimeAsync(3_500)
@@ -153,7 +154,7 @@ describe('runFalQueue on a request that never finishes', () => {
153154
const fal = stubStuckFalQueue()
154155

155156
await expect(
156-
runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', 'generate_video', AbortSignal.abort())
157+
runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', FAL_KEY_USE, AbortSignal.abort())
157158
).rejects.toThrow()
158159
expect(fal.calls).toEqual([])
159160
})
@@ -180,7 +181,7 @@ describe('runFalQueue on a request that never finishes', () => {
180181
'fal-ai/model',
181182
{ prompt: 'p' },
182183
'key',
183-
'generate_video',
184+
FAL_KEY_USE,
184185
caller.signal
185186
).catch(() => 'rejected')
186187

@@ -199,7 +200,7 @@ describe('runFalQueue on a request that never finishes', () => {
199200
'fal-ai/model',
200201
{ prompt: 'p' },
201202
'key',
202-
'generate_video',
203+
FAL_KEY_USE,
203204
caller.signal
204205
).catch(() => undefined)
205206
await vi.advanceTimersByTimeAsync(0)

‎apps/sim/lib/media/falai.ts‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ const FAL_CANCEL_TIMEOUT_MS = 5000
3434
*/
3535
const FAL_SUBMIT_TIMEOUT_MS = 60_000
3636

37-
/** The Mothership tools that generate media on Sim's hosted Fal.ai keys. */
38-
type FalMediaTool = 'generate_audio' | 'generate_video'
37+
/** Who spent a hosted Fal.ai key: the Mothership tool and the env var the key came from. */
38+
interface FalKeyUse {
39+
tool: 'generate_audio' | 'generate_video'
40+
envVarName: string
41+
}
3942

4043
/**
4144
* Statuses fal answers when the hosted account itself refuses the call (a revoked key, or an
@@ -48,15 +51,16 @@ const FAL_ACCOUNT_REFUSAL_STATUSES = new Set([401, 402, 403])
4851
* (FALAI_API_KEY_COUNT + FALAI_API_KEY_1..N), round-robined by minute,
4952
* mirroring getRotatingApiKey. Falls back to a single FALAI_API_KEY for dev.
5053
*/
51-
export function getFalApiKey(): string {
54+
export function getFalApiKey(): { apiKey: string; envVarName: string } {
5255
const count = Number.parseInt(process.env.FALAI_API_KEY_COUNT || '0', 10)
53-
const keys: string[] = []
56+
const keys: Array<{ apiKey: string; envVarName: string }> = []
5457
for (let i = 1; i <= count; i++) {
55-
const key = process.env[`FALAI_API_KEY_${i}`]
56-
if (key) keys.push(key)
58+
const envVarName = `FALAI_API_KEY_${i}`
59+
const apiKey = process.env[envVarName]
60+
if (apiKey) keys.push({ apiKey, envVarName })
5761
}
5862
if (keys.length === 0 && process.env.FALAI_API_KEY) {
59-
keys.push(process.env.FALAI_API_KEY)
63+
keys.push({ apiKey: process.env.FALAI_API_KEY, envVarName: 'FALAI_API_KEY' })
6064
}
6165
if (keys.length === 0) {
6266
throw new Error(
@@ -111,7 +115,7 @@ export async function runFalQueue(
111115
endpoint: string,
112116
input: Record<string, unknown>,
113117
apiKey: string,
114-
tool: FalMediaTool,
118+
keyUse: FalKeyUse,
115119
signal?: AbortSignal
116120
): Promise<FalQueueResult> {
117121
const ceiling = new AbortController()
@@ -121,7 +125,7 @@ export async function runFalQueue(
121125
)
122126
const queueSignal = signal ? AbortSignal.any([signal, ceiling.signal]) : ceiling.signal
123127
try {
124-
return await submitAndPollFalQueue(endpoint, input, apiKey, tool, queueSignal)
128+
return await submitAndPollFalQueue(endpoint, input, apiKey, keyUse, queueSignal)
125129
} finally {
126130
clearTimeout(ceilingTimer)
127131
}
@@ -131,7 +135,7 @@ async function submitAndPollFalQueue(
131135
endpoint: string,
132136
input: Record<string, unknown>,
133137
apiKey: string,
134-
tool: FalMediaTool,
138+
keyUse: FalKeyUse,
135139
queueSignal: AbortSignal
136140
): Promise<FalQueueResult> {
137141
// Only a submit already under way outlives its caller; a stopped call never starts one.
@@ -150,8 +154,8 @@ async function submitAndPollFalQueue(
150154
if (FAL_ACCOUNT_REFUSAL_STATUSES.has(createResponse.status)) {
151155
hostedKeyMetrics.recordFailed({
152156
provider: 'falai',
153-
tool,
154-
key: 'FALAI_API_KEY',
157+
tool: keyUse.tool,
158+
key: keyUse.envVarName,
155159
reason: 'auth',
156160
})
157161
}

‎apps/sim/lib/monitoring/metrics.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ type QueueReason = 'actor_requests' | 'dimension' | 'queue_position'
4646
/** `metering` marks a provider call that succeeded but could not be priced. */
4747
type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering'
4848

49-
// Hosted keys exist only on Sim Cloud, whose app and workers can always reach
50-
// CloudWatch (task role or workload identity). Elsewhere recorders are no-ops,
51-
// so they stay always-safe to call.
52-
const ENABLED = isHosted
53-
5449
const ENVIRONMENT =
5550
process.env.OTEL_DEPLOYMENT_ENVIRONMENT ||
5651
process.env.DEPLOYMENT_ENVIRONMENT ||
@@ -98,13 +93,16 @@ function buildDimensions(labels: Record<string, string | undefined>) {
9893
return dimensions
9994
}
10095

96+
// Hosted keys exist only on Sim Cloud, whose app and workers can always reach
97+
// CloudWatch (task role or workload identity). Elsewhere recorders are no-ops,
98+
// so they stay always-safe to call.
10199
function enqueue(
102100
MetricName: string,
103101
Value: number,
104102
Unit: StandardUnit,
105103
labels: Record<string, string | undefined>
106104
): void {
107-
if (!ENABLED) return
105+
if (!isHosted) return
108106
buffer.push({
109107
MetricName,
110108
Value,
@@ -129,7 +127,7 @@ export async function flushHostedKeyMetrics(): Promise<void> {
129127
logger.warn('Dropped hosted-key metric points (buffer cap reached)', { dropped })
130128
dropped = 0
131129
}
132-
if (!ENABLED || buffer.length === 0) return
130+
if (!isHosted || buffer.length === 0) return
133131
const pending = buffer
134132
buffer = []
135133
for (let i = 0; i < pending.length; i += MAX_BATCH) {

‎scripts/check-unused-exports.baseline.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4702,7 +4702,6 @@
47024702
"apps/sim/lib/messaging/email/mailer.ts#sendBatchEmails",
47034703
"apps/sim/lib/messaging/email/types.ts#EmailAttachment",
47044704
"apps/sim/lib/messaging/email/utils.ts#extractEmailFromAddress",
4705-
"apps/sim/lib/monitoring/metrics.ts#flushHostedKeyMetrics",
47064705
"apps/sim/lib/mothership/agent-cli/types.ts#AgentCliClient",
47074706
"apps/sim/lib/mothership/application/execute-knowledge-use-case.ts#requireCopilotKnowledgeWorkspaceId",
47084707
"apps/sim/lib/mothership/application/execute-knowledge-use-case.ts#resolveCopilotKnowledgePrincipal",

0 commit comments

Comments
 (0)