Skip to content

Commit 282357b

Browse files
authored
feat: Validate token count does not exceed limit (#4056)
* feat: add tokenizer library * feat: add debounced token calculation * feat: outsource warning and error texts into translations * feat: disable submission should calculation fail * feat: outsource functionality into separate hook * feat: enhance token usage warning information * feat: outsource constants into config * feat: return max tokens from hook to cover fallback * test: add unit test for new hook * fix: hide joule feedback on cluster list * test: add integration test * test: adjust test config
1 parent 4cebfa6 commit 282357b

File tree

12 files changed

+664
-62
lines changed

12 files changed

+664
-62
lines changed

kyma/environments/dev/config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ config:
7272
isEnabled: true
7373
config:
7474
feedbackLink: https://www.youtube.com/watch?v=dQw4w9WgXcQ
75+
model: 'gpt-4.1'
76+
queryMaxTokens: 8000
7577
TRACKING:
7678
isEnabled: false
7779
GARDENER_LOGIN:

kyma/environments/prod/config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ config:
7575
isEnabled: false
7676
config:
7777
feedbackLink: ''
78+
model: ''
79+
queryMaxTokens: 0
7880
GARDENER_LOGIN:
7981
isEnabled: false
8082
kubeconfig: null

kyma/environments/stage/config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ config:
7373
isEnabled: true
7474
config:
7575
feedbackLink: https://sapinsights.eu.qualtrics.com/jfe/form/SV_dmWATTfes5SstG6
76+
model: 'gpt-4.1'
77+
queryMaxTokens: 8000
7678
GARDENER_LOGIN:
7779
isEnabled: false
7880
kubeconfig: null

package-lock.json

Lines changed: 19 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"i18next": "^24.2.3",
8787
"i18next-http-backend": "^3.0.2",
8888
"immutable": "^5.1.1",
89+
"js-tiktoken": "^1.0.20",
8990
"js-yaml": "^4.1.0",
9091
"jsonata": "^1.8.7",
9192
"jsonpath": "^1.1.1",

public/defaultConfig.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ config:
5656
isEnabled: false
5757
config:
5858
feedbackLink: https://sapinsights.eu.qualtrics.com/jfe/preview/previewId/1fa80b40-56c3-4b20-9902-877df3da3493/SV_dmWATTfes5SstG6?Q_CHL=preview&Q_SurveyVersionID=current
59+
model: 'gpt-4.1'
60+
queryMaxTokens: 8000
5961
TRACKING:
6062
isEnabled: false
6163
GARDENER_LOGIN:

public/i18n/en.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,10 @@ kyma-companion:
788788
suggestions-error: No suggestions available
789789
http-error: Response status code is {{statusCode}}. Retrying {{attempt}}.
790790
http-error-no-retry: Response status code is {{statusCode}}.
791+
input-tokens:
792+
warning: "Approaching per-query token limit ({{tokenCount}}/{{maxTokens}} tokens used)"
793+
error: "Message exceeds per-query token limit ({{tokenCount}}/{{maxTokens}} tokens used)"
794+
calculation-error: Unable to calculate token count.
791795
introduction: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. Meanwhile, you can check the suggested questions below; you may find them helpful!
792796
introduction-no-suggestions: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. While I don't have any initial suggestions for this resource, feel free to ask me anything you'd like!
793797
placeholder: Message Joule...

src/components/KymaCompanion/components/Chat/Input/QueryInput.tsx

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
} from '@ui5/webcomponents-react';
88
import { useEffect, useRef, useState, useCallback } from 'react';
99
import { useTranslation } from 'react-i18next';
10+
import { useFeature } from 'hooks/useFeature';
11+
import { configFeaturesNames } from 'state/types';
12+
import { useTokenValidation } from 'components/KymaCompanion/hooks/useTokenValidation';
1013
import './QueryInput.scss';
1114

1215
// Layout constants
@@ -39,6 +42,19 @@ export default function QueryInput({
3942
const [maxRows, setMaxRows] = useState(0);
4043
const [isMultiRowMode, setIsMultiRowMode] = useState(false);
4144

45+
const { config: companionConfig } = useFeature(
46+
configFeaturesNames.KYMA_COMPANION,
47+
);
48+
49+
const {
50+
isTokenLimitExceeded,
51+
showTokenWarning,
52+
tokenError,
53+
tokenCount,
54+
validateTokenCount,
55+
maxTokens,
56+
} = useTokenValidation(inputValue, companionConfig);
57+
4258
const checkRowCount = useCallback(() => {
4359
if (!textareaRef.current) return;
4460

@@ -59,11 +75,9 @@ export default function QueryInput({
5975

6076
const canvasHeight = containerRef.current.clientHeight;
6177
const maxAllowedHeight = canvasHeight * MAX_HEIGHT_RATIO;
62-
6378
const availableContentHeight =
6479
maxAllowedHeight - 2 * PADDING_BLOCK - 2 * BORDER_SIZE;
6580
const calculatedMaxRows = Math.floor(availableContentHeight / LINE_HEIGHT);
66-
6781
const finalMaxRows = Math.max(
6882
1,
6983
Math.min(calculatedMaxRows, FALLBACK_MAX_ROWS),
@@ -73,11 +87,42 @@ export default function QueryInput({
7387

7488
const onSubmitInput = () => {
7589
if (inputValue.length === 0) return;
90+
91+
// Immediate token validation before submission (bypasses debounce)
92+
const { isValid } = validateTokenCount(inputValue);
93+
if (!isValid) {
94+
return;
95+
}
96+
7697
const prompt = inputValue;
7798
setInputValue('');
7899
sendPrompt(prompt);
79100
};
80101

102+
const getValueState = () => {
103+
if (isTokenLimitExceeded || tokenError) return 'Negative';
104+
if (showTokenWarning) return 'Critical';
105+
return 'None';
106+
};
107+
108+
const getValueStateMessage = () => {
109+
if (showTokenWarning)
110+
return (
111+
<Text>
112+
{t('kyma-companion.input-tokens.warning', { tokenCount, maxTokens })}
113+
</Text>
114+
);
115+
if (isTokenLimitExceeded)
116+
return (
117+
<Text>
118+
{t('kyma-companion.input-tokens.error', { tokenCount, maxTokens })}
119+
</Text>
120+
);
121+
if (tokenError)
122+
return <Text>{t('kyma-companion.input-tokens.calculation-error')}</Text>;
123+
return null;
124+
};
125+
81126
useEffect(() => {
82127
if (!loading && textareaRef.current) {
83128
requestAnimationFrame(() => {
@@ -90,44 +135,29 @@ export default function QueryInput({
90135
}, [loading]);
91136

92137
useEffect(() => {
93-
requestAnimationFrame(() => {
94-
calculateMaxRows();
95-
});
96-
138+
requestAnimationFrame(() => calculateMaxRows());
97139
const handleWindowResize = () => calculateMaxRows();
98140
window.addEventListener('resize', handleWindowResize);
99-
100-
return () => {
101-
window.removeEventListener('resize', handleWindowResize);
102-
};
141+
return () => window.removeEventListener('resize', handleWindowResize);
103142
}, [calculateMaxRows]);
104143

105144
useEffect(() => {
106145
if (!textareaRef.current) return;
107-
108146
const textarea = textareaRef.current;
109-
const resizeObserver = new ResizeObserver(() => {
110-
checkRowCount();
111-
});
112-
147+
const resizeObserver = new ResizeObserver(() => checkRowCount());
113148
resizeObserver.observe(textarea);
114-
115-
return () => {
116-
resizeObserver.disconnect();
117-
};
149+
return () => resizeObserver.disconnect();
118150
}, [checkRowCount]);
119151

120152
useEffect(() => {
121153
requestAnimationFrame(() => {
122154
const textarea = textareaRef.current;
123-
124155
const mirrorElement = textarea?.shadowRoot?.querySelector(
125156
'.ui5-textarea-mirror',
126157
) as HTMLElement;
127158
const innerElement = textarea?.shadowRoot?.querySelector(
128159
'.ui5-textarea-inner',
129160
) as HTMLElement;
130-
131161
if (mirrorElement && innerElement) {
132162
const padding = isMultiRowMode ? PADDING_MULTI_ROW : PADDING_SINGLE_ROW;
133163
mirrorElement.style.paddingRight = padding;
@@ -148,16 +178,15 @@ export default function QueryInput({
148178
rows={1}
149179
placeholder={t('kyma-companion.placeholder')}
150180
value={inputValue}
181+
valueState={getValueState()}
182+
valueStateMessage={getValueStateMessage()}
151183
onKeyDown={e => {
152184
if (e.key === 'Enter' && !e.shiftKey) {
153185
e.preventDefault();
154186
onSubmitInput();
155187
}
156188
}}
157-
onInput={e => {
158-
setInputValue(e.target.value);
159-
}}
160-
valueState="None"
189+
onInput={e => setInputValue(e.target.value)}
161190
/>
162191
<div
163192
className={`query-input-actions${isMultiRowMode ? '__column' : ''}`}
@@ -175,7 +204,12 @@ export default function QueryInput({
175204
id="submit-icon"
176205
icon="paper-plane"
177206
design="Emphasized"
178-
disabled={loading || inputValue.length === 0}
207+
disabled={
208+
loading ||
209+
inputValue.length === 0 ||
210+
isTokenLimitExceeded ||
211+
tokenError
212+
}
179213
onClick={onSubmitInput}
180214
/>
181215
</div>

0 commit comments

Comments
 (0)