d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
mockGetApiKeyWithBYOK,
|
|
mockGetBYOKKey,
|
|
mockGetProviderFromModel,
|
|
mockCalculateCost,
|
|
mockShouldBill,
|
|
mockResolveVertex,
|
|
} = vi.hoisted(() => ({
|
|
mockGetApiKeyWithBYOK: vi.fn(),
|
|
mockGetBYOKKey: vi.fn(),
|
|
mockGetProviderFromModel: vi.fn(),
|
|
mockCalculateCost: vi.fn(),
|
|
mockShouldBill: vi.fn(),
|
|
mockResolveVertex: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/api-key/byok', () => ({
|
|
getApiKeyWithBYOK: mockGetApiKeyWithBYOK,
|
|
getBYOKKey: mockGetBYOKKey,
|
|
}))
|
|
vi.mock('@/providers/utils', () => ({
|
|
getProviderFromModel: mockGetProviderFromModel,
|
|
calculateCost: mockCalculateCost,
|
|
shouldBillModelUsage: mockShouldBill,
|
|
}))
|
|
vi.mock('@/executor/utils/vertex-credential', () => ({
|
|
resolveVertexCredential: mockResolveVertex,
|
|
}))
|
|
vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: () => 2 }))
|
|
|
|
import { computePiCost, providerApiKeyEnvVar, resolvePiModelKey } from '@/executor/handlers/pi/keys'
|
|
|
|
describe('providerApiKeyEnvVar', () => {
|
|
it('maps key-based providers and rejects unsupported ones', () => {
|
|
expect(providerApiKeyEnvVar('anthropic')).toBe('ANTHROPIC_API_KEY')
|
|
expect(providerApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY')
|
|
expect(providerApiKeyEnvVar('vertex')).toBeNull()
|
|
expect(providerApiKeyEnvVar('bedrock')).toBeNull()
|
|
expect(providerApiKeyEnvVar('something-else')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('computePiCost', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns zero cost for BYOK keys without billing', () => {
|
|
expect(computePiCost('claude', 100, 200, true)).toEqual({ 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(mockCalculateCost).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('computes billed cost with the cost multiplier', () => {
|
|
mockShouldBill.mockReturnValue(true)
|
|
mockCalculateCost.mockReturnValue({ input: 1, output: 2, total: 3 })
|
|
expect(computePiCost('claude', 10, 20, false)).toEqual({ input: 1, output: 2, total: 3 })
|
|
expect(mockCalculateCost).toHaveBeenCalledWith('claude', 10, 20, false, 2, 2)
|
|
})
|
|
})
|
|
|
|
describe('resolvePiModelKey', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('resolves Vertex credentials when the provider is vertex', async () => {
|
|
mockGetProviderFromModel.mockReturnValue('vertex')
|
|
mockResolveVertex.mockResolvedValue('vertex-token')
|
|
|
|
const result = await resolvePiModelKey({
|
|
model: 'gemini-pro',
|
|
mode: 'local',
|
|
userId: 'user-1',
|
|
vertexCredential: 'cred-1',
|
|
})
|
|
|
|
expect(result).toEqual({ providerId: 'vertex', apiKey: 'vertex-token', isBYOK: true })
|
|
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('local mode resolves keys through getApiKeyWithBYOK (hosted keys allowed)', async () => {
|
|
mockGetProviderFromModel.mockReturnValue('anthropic')
|
|
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-test', isBYOK: false })
|
|
|
|
const result = await resolvePiModelKey({
|
|
model: 'claude',
|
|
mode: 'local',
|
|
workspaceId: 'ws-1',
|
|
apiKey: 'sk-test',
|
|
})
|
|
|
|
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-test', isBYOK: false })
|
|
expect(mockGetApiKeyWithBYOK).toHaveBeenCalledWith('anthropic', 'claude', 'ws-1', 'sk-test')
|
|
})
|
|
|
|
it('cloud mode uses the block API Key field directly as a BYOK key', async () => {
|
|
mockGetProviderFromModel.mockReturnValue('anthropic')
|
|
|
|
const result = await resolvePiModelKey({
|
|
model: 'claude',
|
|
mode: 'cloud',
|
|
workspaceId: 'ws-1',
|
|
apiKey: 'sk-user',
|
|
})
|
|
|
|
expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true })
|
|
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
|
|
expect(mockGetBYOKKey).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('cloud mode falls back to a stored workspace key when the field is empty', async () => {
|
|
mockGetProviderFromModel.mockReturnValue('openai')
|
|
mockGetBYOKKey.mockResolvedValue({ apiKey: 'sk-workspace', isBYOK: true })
|
|
|
|
const result = await resolvePiModelKey({
|
|
model: 'gpt-5',
|
|
mode: 'cloud',
|
|
workspaceId: 'ws-1',
|
|
})
|
|
|
|
expect(result).toEqual({ providerId: 'openai', apiKey: 'sk-workspace', isBYOK: true })
|
|
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'openai')
|
|
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('cloud mode falls back to a stored workspace key for xAI', async () => {
|
|
mockGetProviderFromModel.mockReturnValue('xai')
|
|
mockGetBYOKKey.mockResolvedValue({ apiKey: 'xai-workspace-key', isBYOK: true })
|
|
|
|
const result = await resolvePiModelKey({
|
|
model: 'grok-4.5',
|
|
mode: 'cloud',
|
|
workspaceId: 'ws-1',
|
|
})
|
|
|
|
expect(result).toEqual({ providerId: 'xai', apiKey: 'xai-workspace-key', isBYOK: true })
|
|
expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'xai')
|
|
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('cloud mode rejects when no user key is available (never a hosted key)', async () => {
|
|
mockGetProviderFromModel.mockReturnValue('anthropic')
|
|
mockGetBYOKKey.mockResolvedValue(null)
|
|
|
|
await expect(
|
|
resolvePiModelKey({ model: 'claude', mode: 'cloud', workspaceId: 'ws-1' })
|
|
).rejects.toThrow(/your own provider API key/)
|
|
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
|
|
})
|
|
})
|