Skip to content

Commit e36092e

Browse files
committed
feat: add LLM provider pricing info dialog
Add a pricing information dialog for server-configured LLM providers. Organization owners can now see token pricing in credits and estimated costs for translating 1,000 strings. - Extend LlmProviderSimpleModel with tokenPriceInCreditsInput/Output - Create LlmProviderPricingDialog with token prices table, cost estimates (with/without screenshots), and info notes - Create LlmProviderPricingInfo as self-contained icon + dialog - Only show pricing info when billing is enabled - Fetch subscription data to convert credits to USD - Add E2E test verifying pricing info is hidden without billing
1 parent f9cfae0 commit e36092e

7 files changed

Lines changed: 316 additions & 5 deletions

File tree

backend/api/src/main/kotlin/io/tolgee/hateoas/llmProvider/LlmProviderSimpleModel.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ open class LlmProviderSimpleModel(
99
var name: String,
1010
var source: String?,
1111
var type: LlmProviderType,
12+
var tokenPriceInCreditsInput: Double?,
13+
var tokenPriceInCreditsOutput: Double?,
1214
) : RepresentationModel<LlmProviderModel>()

e2e/cypress/e2e/llmProviders/llmProviders.cy.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ describe('basic prompt', () => {
100100
}).should('have.text', 'OpenAI');
101101
});
102102

103+
it('does not show pricing info when billing is disabled', () => {
104+
gcy('organization-llm-providers-tab').contains('Server').click();
105+
gcy('llm-provider-item-name').should('contain', 'server-provider');
106+
gcy('llm-provider-pricing-info').should('not.exist');
107+
});
108+
103109
function visitLlmProviders(organizationSlug: string) {
104110
cy.visit(`${HOST}/organizations/${organizationSlug}/llm-providers`);
105111
}

e2e/cypress/support/dataCyType.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ declare namespace DataCy {
478478
"llm-provider-item-type" |
479479
"llm-provider-menu-item-delete" |
480480
"llm-provider-menu-item-edit" |
481+
"llm-provider-pricing-info" |
481482
"llm-providers-custom" |
482483
"llm-providers-server" |
483484
"login-button" |

ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/hateoas/assemblers/LlmProviderSimpleModelAssembler.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class LlmProviderSimpleModelAssembler :
1818
entity.name,
1919
if (entity is LlmProvider) "organization" else "server",
2020
entity.type,
21+
entity.tokenPriceInCreditsInput,
22+
entity.tokenPriceInCreditsOutput,
2123
)
2224
}
2325
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import {
2+
Box,
3+
Button,
4+
Dialog,
5+
DialogActions,
6+
DialogContent,
7+
DialogTitle,
8+
Table,
9+
TableBody,
10+
TableCell,
11+
TableHead,
12+
TableRow,
13+
Typography,
14+
} from '@mui/material';
15+
import { T, useTranslate } from '@tolgee/react';
16+
17+
type ProviderWithPricing = {
18+
name: string;
19+
tokenPriceInCreditsInput?: number | null;
20+
tokenPriceInCreditsOutput?: number | null;
21+
};
22+
23+
type Props = {
24+
provider: ProviderWithPricing;
25+
perThousandMtCredits: number | null;
26+
onClose: () => void;
27+
};
28+
29+
const INPUT_TOKENS_WITH_CONTEXT = 1100;
30+
const INPUT_TOKENS_WITHOUT_CONTEXT = 700;
31+
const OUTPUT_TOKENS = 100;
32+
const STRINGS_COUNT = 1000;
33+
34+
function formatCredits(value: number): string {
35+
if (value < 0.01) {
36+
return value.toExponential(2);
37+
}
38+
return value.toFixed(4);
39+
}
40+
41+
function formatUsd(value: number): string {
42+
return `$${value.toFixed(4)}`;
43+
}
44+
45+
export const LlmProviderPricingDialog = ({
46+
provider,
47+
perThousandMtCredits,
48+
onClose,
49+
}: Props) => {
50+
const { t } = useTranslate();
51+
52+
const inputPrice = provider.tokenPriceInCreditsInput;
53+
const outputPrice = provider.tokenPriceInCreditsOutput;
54+
const hasPricing = inputPrice != null && outputPrice != null;
55+
const pricePerMtCredit =
56+
perThousandMtCredits != null ? perThousandMtCredits / 1000 : null;
57+
58+
function creditsToUsd(credits: number): string | null {
59+
if (pricePerMtCredit == null) return null;
60+
return formatUsd(credits * pricePerMtCredit);
61+
}
62+
63+
function estimateCredits(
64+
inputTokensPerString: number,
65+
outputTokensPerString: number
66+
): number | null {
67+
if (inputPrice == null || outputPrice == null) return null;
68+
return (
69+
(inputTokensPerString * inputPrice +
70+
outputTokensPerString * outputPrice) *
71+
STRINGS_COUNT
72+
);
73+
}
74+
75+
return (
76+
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
77+
<DialogTitle>
78+
{t('llm_provider_pricing_dialog_title')}{provider.name}
79+
</DialogTitle>
80+
<DialogContent>
81+
{!hasPricing ? (
82+
<Typography color="text.secondary">
83+
<T keyName="llm_provider_pricing_no_pricing" />
84+
</Typography>
85+
) : (
86+
<Box display="grid" gap={3}>
87+
<Box>
88+
<Typography variant="subtitle2" gutterBottom>
89+
<T keyName="llm_provider_pricing_token_prices" />
90+
</Typography>
91+
<Table size="small">
92+
<TableHead>
93+
<TableRow>
94+
<TableCell />
95+
<TableCell>
96+
<T keyName="llm_provider_pricing_per_token_credits" />
97+
</TableCell>
98+
{pricePerMtCredit != null && (
99+
<TableCell>
100+
<T keyName="llm_provider_pricing_per_token_usd" />
101+
</TableCell>
102+
)}
103+
</TableRow>
104+
</TableHead>
105+
<TableBody>
106+
<TableRow>
107+
<TableCell>
108+
<T keyName="llm_provider_pricing_input" />
109+
</TableCell>
110+
<TableCell>{formatCredits(inputPrice!)}</TableCell>
111+
{pricePerMtCredit != null && (
112+
<TableCell>{creditsToUsd(inputPrice!)}</TableCell>
113+
)}
114+
</TableRow>
115+
<TableRow>
116+
<TableCell>
117+
<T keyName="llm_provider_pricing_output" />
118+
</TableCell>
119+
<TableCell>{formatCredits(outputPrice!)}</TableCell>
120+
{pricePerMtCredit != null && (
121+
<TableCell>{creditsToUsd(outputPrice!)}</TableCell>
122+
)}
123+
</TableRow>
124+
</TableBody>
125+
</Table>
126+
</Box>
127+
128+
<Box>
129+
<Typography variant="subtitle2" gutterBottom>
130+
<T keyName="llm_provider_pricing_estimate_title" />
131+
</Typography>
132+
<Table size="small">
133+
<TableHead>
134+
<TableRow>
135+
<TableCell />
136+
<TableCell>
137+
<T keyName="llm_provider_pricing_credits" />
138+
</TableCell>
139+
{pricePerMtCredit != null && (
140+
<TableCell>
141+
<T keyName="llm_provider_pricing_per_token_usd" />
142+
</TableCell>
143+
)}
144+
</TableRow>
145+
</TableHead>
146+
<TableBody>
147+
<TableRow>
148+
<TableCell>
149+
<T keyName="llm_provider_pricing_with_screenshots" />
150+
</TableCell>
151+
<TableCell>
152+
{formatCredits(
153+
estimateCredits(
154+
INPUT_TOKENS_WITH_CONTEXT,
155+
OUTPUT_TOKENS
156+
)!
157+
)}
158+
</TableCell>
159+
{pricePerMtCredit != null && (
160+
<TableCell>
161+
{creditsToUsd(
162+
estimateCredits(
163+
INPUT_TOKENS_WITH_CONTEXT,
164+
OUTPUT_TOKENS
165+
)!
166+
)}
167+
</TableCell>
168+
)}
169+
</TableRow>
170+
<TableRow>
171+
<TableCell>
172+
<T keyName="llm_provider_pricing_without_screenshots" />
173+
</TableCell>
174+
<TableCell>
175+
{formatCredits(
176+
estimateCredits(
177+
INPUT_TOKENS_WITHOUT_CONTEXT,
178+
OUTPUT_TOKENS
179+
)!
180+
)}
181+
</TableCell>
182+
{pricePerMtCredit != null && (
183+
<TableCell>
184+
{creditsToUsd(
185+
estimateCredits(
186+
INPUT_TOKENS_WITHOUT_CONTEXT,
187+
OUTPUT_TOKENS
188+
)!
189+
)}
190+
</TableCell>
191+
)}
192+
</TableRow>
193+
</TableBody>
194+
</Table>
195+
</Box>
196+
197+
<Box display="grid" gap={1}>
198+
<Typography variant="body2" color="text.secondary">
199+
<T keyName="llm_provider_pricing_margin_note" />
200+
</Typography>
201+
<Typography variant="body2" color="text.secondary">
202+
<T keyName="llm_provider_pricing_plan_note" />
203+
</Typography>
204+
</Box>
205+
</Box>
206+
)}
207+
</DialogContent>
208+
<DialogActions>
209+
<Button onClick={onClose}>{t('global_close_button')}</Button>
210+
</DialogActions>
211+
</Dialog>
212+
);
213+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Box, IconButton, styled, Tooltip } from '@mui/material';
2+
import { useState } from 'react';
3+
import { InfoCircle } from '@untitled-ui/icons-react';
4+
import { useTranslate } from '@tolgee/react';
5+
import { LlmProviderPricingDialog } from './LlmProviderPricingDialog';
6+
7+
const StyledAction = styled(Box)`
8+
display: flex;
9+
align-items: center;
10+
align-self: stretch;
11+
justify-self: stretch;
12+
color: ${({ theme }) => theme.palette.text.secondary};
13+
padding-right: 8px;
14+
`;
15+
16+
type ProviderWithPricing = {
17+
name: string;
18+
tokenPriceInCreditsInput?: number;
19+
tokenPriceInCreditsOutput?: number;
20+
};
21+
22+
type Props = {
23+
provider: ProviderWithPricing;
24+
perThousandMtCredits: number | null;
25+
};
26+
27+
export const LlmProviderPricingInfo = ({
28+
provider,
29+
perThousandMtCredits,
30+
}: Props) => {
31+
const { t } = useTranslate();
32+
const [open, setOpen] = useState(false);
33+
34+
return (
35+
<>
36+
<StyledAction data-cy="llm-provider-pricing-info">
37+
<Tooltip title={t('llm_provider_pricing_dialog_title')}>
38+
<IconButton
39+
size="small"
40+
color="inherit"
41+
onClick={() => setOpen(true)}
42+
>
43+
<InfoCircle />
44+
</IconButton>
45+
</Tooltip>
46+
</StyledAction>
47+
{open && (
48+
<LlmProviderPricingDialog
49+
provider={provider}
50+
perThousandMtCredits={perThousandMtCredits}
51+
onClose={() => setOpen(false)}
52+
/>
53+
)}
54+
</>
55+
);
56+
};

webapp/src/ee/llm/OrganizationLLMProviders/LlmProvidersServer.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1+
import { Fragment } from 'react';
12
import { Box, styled } from '@mui/material';
2-
import { useApiQuery } from 'tg.service/http/useQueryApi';
3+
import { useApiQuery, useBillingApiQuery } from 'tg.service/http/useQueryApi';
34
import { useOrganization } from 'tg.views/organizations/useOrganization';
5+
import { useConfig } from 'tg.globalContext/helpers';
46
import { LlmProviderItem } from './LlmProviderItem';
7+
import { LlmProviderPricingInfo } from './LlmProviderPricingInfo';
58
import { T } from '@tolgee/react';
69

7-
const StyledContainer = styled(Box)`
10+
const StyledContainer = styled(Box)<{ columns: number }>`
811
display: grid;
9-
grid-template-columns: minmax(35%, max-content) 1fr;
12+
grid-template-columns: ${({ columns }) =>
13+
columns === 3
14+
? 'minmax(35%, max-content) 1fr auto'
15+
: 'minmax(35%, max-content) 1fr'};
1016
align-items: center;
1117
`;
1218

1319
export const LlmProvidersServer = () => {
1420
const organization = useOrganization();
21+
const config = useConfig();
22+
const billingEnabled = config.billing.enabled;
1523

1624
const serverProvidersLoadable = useApiQuery({
1725
url: '/v2/organizations/{organizationId}/llm-providers/server-providers',
@@ -21,14 +29,37 @@ export const LlmProvidersServer = () => {
2129
},
2230
});
2331

32+
const subscriptionLoadable = useBillingApiQuery({
33+
url: '/v2/organizations/{organizationId}/billing/subscription',
34+
method: 'get',
35+
path: {
36+
organizationId: organization!.id,
37+
},
38+
options: {
39+
enabled: billingEnabled,
40+
retry: false,
41+
},
42+
});
43+
44+
const perThousandMtCredits =
45+
subscriptionLoadable.data?.plan?.prices?.perThousandMtCredits ?? null;
46+
2447
return (
2548
<Box display="grid" gap={3} mt={3}>
2649
<Box>
2750
<T keyName="llm_providers_server_description" />
2851
</Box>
29-
<StyledContainer>
52+
<StyledContainer columns={billingEnabled ? 3 : 2}>
3053
{serverProvidersLoadable.data?._embedded?.providers?.map((p, i) => (
31-
<LlmProviderItem key={i} provider={p} />
54+
<Fragment key={i}>
55+
<LlmProviderItem provider={p} />
56+
{billingEnabled && (
57+
<LlmProviderPricingInfo
58+
provider={p}
59+
perThousandMtCredits={perThousandMtCredits}
60+
/>
61+
)}
62+
</Fragment>
3263
))}
3364
</StyledContainer>
3465
</Box>

0 commit comments

Comments
 (0)