Skip to content

Commit 55a4115

Browse files
committed
Decouple Max Tokens from Max Thinking Tokens
1 parent af2e9ed commit 55a4115

File tree

7 files changed

+623
-332
lines changed

7 files changed

+623
-332
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from "react"
2+
3+
// This is a manual mock for the Slider component.
4+
// It will be automatically used by Jest when jest.mock('../Slider') is called.
5+
6+
// Create a Jest mock function for the component itself
7+
const MockSlider = jest.fn((props: any) => {
8+
// You can add basic rendering if needed for other tests,
9+
// or just keep it simple for prop checking.
10+
// Include data-testid for potential queries if the component rendered something.
11+
return <div data-testid="mock-slider" {...props} />
12+
})
13+
14+
export const Slider = MockSlider

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { TemperatureControl } from "./TemperatureControl"
5454
import { RateLimitSecondsControl } from "./RateLimitSecondsControl"
5555
import { BedrockCustomArn } from "./providers/BedrockCustomArn"
5656
import { buildDocLink } from "@src/utils/docLinks"
57+
import { MaxOutputTokensControl } from "./MaxOutputTokensControl"
5758

5859
export interface ApiOptionsProps {
5960
uriScheme: string | undefined
@@ -462,9 +463,20 @@ const ApiOptions = ({
462463
isDescriptionExpanded={isDescriptionExpanded}
463464
setIsDescriptionExpanded={setIsDescriptionExpanded}
464465
/>
466+
465467
</>
466468
)}
467469

470+
{selectedModelInfo &&
471+
typeof selectedModelInfo.maxTokens === "number" &&
472+
selectedModelInfo.maxTokens > 0 && (
473+
<MaxOutputTokensControl
474+
apiConfiguration={apiConfiguration}
475+
setApiConfigurationField={setApiConfigurationField}
476+
modelInfo={selectedModelInfo}
477+
/>
478+
)}
479+
468480
<ThinkingBudget
469481
key={`${selectedProvider}-${selectedModelId}`}
470482
apiConfiguration={apiConfiguration}
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import React from "react"
2+
import { render, screen } from "@testing-library/react"
3+
import { MaxOutputTokensControl } from "./MaxOutputTokensControl"
4+
import { ApiConfiguration, ModelInfo } from "@roo/shared/api"
5+
6+
// Mock ResizeObserver globally
7+
global.ResizeObserver = jest.fn().mockImplementation(() => ({
8+
observe: jest.fn(),
9+
unobserve: jest.fn(),
10+
disconnect: jest.fn(),
11+
}))
12+
// Static import of Slider removed again to avoid TS error for non-existent module
13+
14+
// Mock i18n
15+
jest.mock("react-i18next", () => ({
16+
useTranslation: () => ({
17+
t: (key: string) => key,
18+
}),
19+
}))
20+
21+
// Use jest.doMock with { virtual: true } to mock a non-existent module.
22+
// This mock factory will be used when '../Slider' is imported.
23+
jest.mock("../ui/slider", () => ({
24+
__esModule: true,
25+
Slider: jest.fn((props) => <div data-testid="mock-slider" {...props} />),
26+
}))
27+
28+
// Import the mocked Slider *after* jest.mock
29+
// We need to refer to it for assertions and clearing.
30+
// eslint-disable-next-line @typescript-eslint/no-var-requires
31+
const MockedSlider = require("../ui/slider").Slider as jest.Mock
32+
33+
describe("MaxOutputTokensControl", () => {
34+
const mockSetApiConfigurationField = jest.fn()
35+
const mockApiConfiguration: ApiConfiguration = {
36+
apiProvider: "openai",
37+
openAiModelId: "test-model",
38+
modelTemperature: 0.5,
39+
modelMaxTokens: undefined,
40+
modelMaxThinkingTokens: undefined,
41+
}
42+
43+
beforeEach(() => {
44+
mockSetApiConfigurationField.mockClear()
45+
MockedSlider.mockClear()
46+
})
47+
48+
it("should render when modelInfo.maxTokens is a positive number", () => {
49+
const modelInfo: Partial<ModelInfo> = {
50+
maxTokens: 16000,
51+
// Add other potentially accessed properties if needed by the component, even if undefined
52+
}
53+
54+
render(
55+
<MaxOutputTokensControl
56+
modelInfo={modelInfo as ModelInfo}
57+
apiConfiguration={mockApiConfiguration}
58+
setApiConfigurationField={mockSetApiConfigurationField}
59+
/>,
60+
)
61+
62+
expect(screen.getByTestId("max-output-tokens-control")).toBeInTheDocument()
63+
})
64+
65+
it("should display the current value and label", () => {
66+
const modelInfo: Partial<ModelInfo> = {
67+
maxTokens: 16000,
68+
}
69+
const apiConfigurationWithOverride: ApiConfiguration = {
70+
...mockApiConfiguration,
71+
modelMaxTokens: 10000, // Override the default maxTokens
72+
}
73+
74+
render(
75+
<MaxOutputTokensControl
76+
modelInfo={modelInfo as ModelInfo}
77+
apiConfiguration={apiConfigurationWithOverride}
78+
setApiConfigurationField={mockSetApiConfigurationField}
79+
/>,
80+
)
81+
82+
// Calculate the expected displayed value
83+
const expectedValue = apiConfigurationWithOverride.modelMaxTokens ?? modelInfo.maxTokens!
84+
85+
// Assert that the current value is displayed
86+
expect(screen.getByText(expectedValue.toString())).toBeInTheDocument()
87+
88+
// Assert that the label is displayed (using the mocked translation key)
89+
expect(screen.getByText("providers.customModel.maxTokens.label")).toBeInTheDocument()
90+
})
91+
92+
// Make the test async to use dynamic import
93+
it("should pass min={8192} to the Slider component when rendered", async () => {
94+
// Dynamically import Slider; it will be the mocked version due to jest.doMock
95+
// Note: For this to work, the test function must be async and you await the import.
96+
// However, for prop checking on a mock function, we can directly use the
97+
// mockSliderImplementation defined in the jest.doMock scope.
98+
// No need for dynamic import if we directly use the mock function instance.
99+
100+
const modelInfo: Partial<ModelInfo> = {
101+
maxTokens: 16000, // A positive number to ensure rendering
102+
}
103+
104+
render(
105+
<MaxOutputTokensControl
106+
modelInfo={modelInfo as ModelInfo}
107+
apiConfiguration={mockApiConfiguration}
108+
setApiConfigurationField={mockSetApiConfigurationField}
109+
/>,
110+
)
111+
112+
// First, ensure the main control is rendered
113+
expect(screen.getByTestId("max-output-tokens-control")).toBeInTheDocument()
114+
115+
// Assert that the mockSliderImplementation (which is what Slider becomes)
116+
// was called with the correct 'min' prop.
117+
// This test is expected to fail because Slider is not yet used or not with this prop.
118+
expect(MockedSlider).toHaveBeenCalledWith(
119+
expect.objectContaining({
120+
min: 8192,
121+
}),
122+
expect.anything(), // Context for React components
123+
)
124+
})
125+
126+
it("should pass max={modelInfo.maxTokens!} to the Slider component when rendered", () => {
127+
const modelInfo: Partial<ModelInfo> = {
128+
maxTokens: 32000, // A positive number to ensure rendering
129+
}
130+
131+
render(
132+
<MaxOutputTokensControl
133+
modelInfo={modelInfo as ModelInfo}
134+
apiConfiguration={mockApiConfiguration}
135+
setApiConfigurationField={mockSetApiConfigurationField}
136+
/>,
137+
)
138+
139+
// First, ensure the main control is rendered
140+
expect(screen.getByTestId("max-output-tokens-control")).toBeInTheDocument()
141+
142+
// Assert that the MockedSlider was called with the correct 'max' prop.
143+
expect(MockedSlider).toHaveBeenCalledWith(
144+
expect.objectContaining({
145+
max: modelInfo.maxTokens,
146+
}),
147+
expect.anything(), // Context for React components
148+
)
149+
})
150+
151+
it("should pass step={1024} to the Slider component when rendered", () => {
152+
const modelInfo: Partial<ModelInfo> = {
153+
maxTokens: 16000, // A positive number to ensure rendering
154+
}
155+
156+
render(
157+
<MaxOutputTokensControl
158+
modelInfo={modelInfo as ModelInfo}
159+
apiConfiguration={mockApiConfiguration}
160+
setApiConfigurationField={mockSetApiConfigurationField}
161+
/>,
162+
)
163+
164+
// First, ensure the main control is rendered
165+
expect(screen.getByTestId("max-output-tokens-control")).toBeInTheDocument()
166+
167+
// Assert that the MockedSlider was called with the correct 'step' prop.
168+
expect(MockedSlider).toHaveBeenCalledWith(
169+
expect.objectContaining({
170+
step: 1024,
171+
}),
172+
expect.anything(), // Context for React components
173+
)
174+
})
175+
176+
describe("should pass the correct initial value to the Slider component", () => {
177+
it("when apiConfiguration.modelMaxTokens is defined", () => {
178+
const modelInfo: Partial<ModelInfo> = {
179+
maxTokens: 32000, // This should be ignored
180+
}
181+
const apiConfigurationWithOverride: ApiConfiguration = {
182+
...mockApiConfiguration,
183+
modelMaxTokens: 10000,
184+
}
185+
186+
render(
187+
<MaxOutputTokensControl
188+
modelInfo={modelInfo as ModelInfo}
189+
apiConfiguration={apiConfigurationWithOverride}
190+
setApiConfigurationField={mockSetApiConfigurationField}
191+
/>,
192+
)
193+
194+
expect(MockedSlider).toHaveBeenCalledWith(
195+
expect.objectContaining({
196+
value: [apiConfigurationWithOverride.modelMaxTokens],
197+
}),
198+
expect.anything(),
199+
)
200+
})
201+
202+
it("when apiConfiguration.modelMaxTokens is undefined", () => {
203+
const modelInfo: Partial<ModelInfo> = {
204+
maxTokens: 16000, // This should be used
205+
}
206+
const apiConfigurationWithoutOverride: ApiConfiguration = {
207+
...mockApiConfiguration,
208+
modelMaxTokens: undefined,
209+
}
210+
211+
render(
212+
<MaxOutputTokensControl
213+
modelInfo={modelInfo as ModelInfo}
214+
apiConfiguration={apiConfigurationWithoutOverride}
215+
setApiConfigurationField={mockSetApiConfigurationField}
216+
/>,
217+
)
218+
219+
expect(MockedSlider).toHaveBeenCalledWith(
220+
expect.objectContaining({
221+
value: [modelInfo.maxTokens!],
222+
}),
223+
expect.anything(),
224+
)
225+
})
226+
227+
it('should call setApiConfigurationField with "modelMaxTokens" and the new value when the slider value changes', () => {
228+
const modelInfo: Partial<ModelInfo> = {
229+
maxTokens: 32000, // A positive number to ensure rendering
230+
}
231+
232+
render(
233+
<MaxOutputTokensControl
234+
modelInfo={modelInfo as ModelInfo}
235+
apiConfiguration={mockApiConfiguration}
236+
setApiConfigurationField={mockSetApiConfigurationField}
237+
/>,
238+
)
239+
240+
// Simulate a value change by calling the onValueChange prop of the mocked Slider
241+
// We need to access the props that the mocked component was called with.
242+
// MockedSlider.mock.calls[0][0] gives us the props of the first render call.
243+
const sliderProps = MockedSlider.mock.calls[0][0]
244+
const newValue = [20000]
245+
sliderProps.onValueChange(newValue)
246+
247+
// Assert that setApiConfigurationField was called with the correct arguments
248+
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", newValue[0])
249+
})
250+
})
251+
252+
describe("should not render when modelInfo.maxTokens is not a positive number", () => {
253+
const testCases = [
254+
{ name: "undefined", value: undefined },
255+
{ name: "null", value: null },
256+
{ name: "0", value: 0 },
257+
]
258+
259+
it.each(testCases)("when maxTokens is $name ($value)", ({ value }) => {
260+
const modelInfo: Partial<ModelInfo> = {
261+
maxTokens: value as any, // Use 'as any' to allow null/undefined for testing
262+
}
263+
264+
render(
265+
<MaxOutputTokensControl
266+
modelInfo={modelInfo as ModelInfo}
267+
apiConfiguration={mockApiConfiguration}
268+
setApiConfigurationField={mockSetApiConfigurationField}
269+
/>,
270+
)
271+
272+
// Check that the component renders null or an empty fragment
273+
// by querying for the test ID and expecting it not to be there.
274+
expect(screen.queryByTestId("max-output-tokens-control")).not.toBeInTheDocument()
275+
// Alternatively, if the component renders null, its container might be empty or contain minimal structure.
276+
// For a component that should render absolutely nothing, its direct container might be empty.
277+
// However, queryByTestId is more robust for checking absence.
278+
})
279+
})
280+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React from "react"
2+
import { ApiConfiguration, ModelInfo } from "@roo/shared/api"
3+
import { Slider } from "../ui/slider"
4+
import { useTranslation } from "react-i18next"
5+
6+
interface MaxOutputTokensControlProps {
7+
apiConfiguration: ApiConfiguration
8+
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
9+
modelInfo?: ModelInfo
10+
}
11+
12+
const MIN_OUTPUT_TOKENS = 8192
13+
const STEP_OUTPUT_TOKENS = 1024
14+
15+
export const MaxOutputTokensControl: React.FC<MaxOutputTokensControlProps> = ({
16+
apiConfiguration,
17+
setApiConfigurationField,
18+
modelInfo,
19+
}) => {
20+
const { t } = useTranslation()
21+
const shouldRender = modelInfo && typeof modelInfo.maxTokens === "number" && modelInfo.maxTokens > 0
22+
23+
if (!shouldRender) {
24+
return null
25+
}
26+
27+
const currentMaxOutputTokens = apiConfiguration.modelMaxTokens ?? modelInfo.maxTokens!
28+
29+
// Basic structure for now, will be expanded in later tasks.
30+
return (
31+
<div data-testid="max-output-tokens-control">
32+
<label className="block mb-2">{t("providers.customModel.maxTokens.label")}</label>
33+
<div className="flex items-center space-x-2">
34+
<Slider
35+
className="flex-grow"
36+
min={MIN_OUTPUT_TOKENS}
37+
max={modelInfo.maxTokens!}
38+
step={STEP_OUTPUT_TOKENS}
39+
value={[currentMaxOutputTokens]}
40+
onValueChange={([value]) => setApiConfigurationField("modelMaxTokens", value)}
41+
/>
42+
<div className="w-12 text-sm text-right">{currentMaxOutputTokens}</div>
43+
</div>
44+
</div>
45+
)
46+
}

0 commit comments

Comments
 (0)