Skip to content

Commit 252f401

Browse files
committed
fix(provider): compose custom reasoning and sampling settings
2 parents 0b4c03c + 25e246b commit 252f401

23 files changed

Lines changed: 1270 additions & 21 deletions

‎src/config.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,51 @@ describe("buildOpenAISource", () => {
13441344
});
13451345
});
13461346

1347+
test("overrides maxTokens and adds temperature to providerOptions", () => {
1348+
const source = buildOpenAISource({
1349+
id: "fp",
1350+
baseURL: "https://fp/v1",
1351+
apiKey: "k",
1352+
model: "m",
1353+
maxTokens: 8192,
1354+
temperature: 0.7,
1355+
});
1356+
expect(source.defaults).toEqual({
1357+
maxTokens: 8192,
1358+
providerOptions: { temperature: 0.7 },
1359+
});
1360+
});
1361+
1362+
test("adds top_p to providerOptions when topP is present", () => {
1363+
const source = buildOpenAISource({
1364+
id: "fp",
1365+
baseURL: "https://fp/v1",
1366+
apiKey: "k",
1367+
model: "m",
1368+
topP: 0.9,
1369+
});
1370+
expect(source.defaults).toEqual({
1371+
maxTokens: SOURCE_MAX_TOKENS,
1372+
providerOptions: { top_p: 0.9 },
1373+
});
1374+
});
1375+
1376+
test("coexists temperature with reasoning_effort in one providerOptions", () => {
1377+
const source = buildOpenAISource({
1378+
id: "fp",
1379+
baseURL: "https://fp/v1",
1380+
apiKey: "k",
1381+
model: "gpt-5.1",
1382+
reasoningEffort: "medium",
1383+
temperature: 1.0,
1384+
maxTokens: 4096,
1385+
});
1386+
expect(source.defaults).toEqual({
1387+
maxTokens: 4096,
1388+
providerOptions: { reasoning_effort: "medium", temperature: 1.0 },
1389+
});
1390+
});
1391+
13471392
test("projects an Ollama root URL to the OpenAI-compatible /v1 endpoint", () => {
13481393
const source = buildOpenAISource({
13491394
id: "ollama/default",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { afterEach, expect, test } from "bun:test";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import {
7+
buildProviderCatalog,
8+
refreshProviderContextWindows,
9+
} from "./index.js";
10+
import { buildMainSessionSources } from "./inference-sources.js";
11+
import { loadSettings } from "./settings.js";
12+
import { clearSourceCredentials } from "./source-credentials.js";
13+
import {
14+
contextWindowFor,
15+
setProviderContextWindowOverrides,
16+
} from "../provider/context-window.js";
17+
import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-adapter.js";
18+
import { buildProviderSubmitHandler } from "../tui/provider/submit.js";
19+
import type { ProviderFormValues } from "../tui/provider/types.js";
20+
21+
afterEach(() => {
22+
clearSourceCredentials();
23+
setProviderContextWindowOverrides(undefined);
24+
});
25+
26+
test("edited custom reasoning and sampling options persist and reach the request together", async () => {
27+
const dir = await mkdtemp(join(tmpdir(), "custom-provider-options-"));
28+
try {
29+
const path = join(dir, "settings.json");
30+
const values: ProviderFormValues = {
31+
name: "combined",
32+
baseURL: "https://combined.example/v1",
33+
apiKey: "",
34+
model: "custom-only",
35+
oauthProfile: "",
36+
reasoningEfforts: ["medium", "high"],
37+
defaultReasoningEffort: "high",
38+
contextWindow: "32000",
39+
maxTokens: "4096",
40+
temperature: "",
41+
topP: "0.9",
42+
};
43+
await buildProviderSubmitHandler(path, null, null)(
44+
values,
45+
() => undefined,
46+
{ skipValidation: true },
47+
);
48+
const original = await loadSettings(path);
49+
if (original === null) throw new Error("missing original settings");
50+
const catalog = buildProviderCatalog(original, {
51+
providerName: values.name,
52+
baseURL: values.baseURL,
53+
apiKey: "",
54+
keyless: true,
55+
model: values.model,
56+
});
57+
58+
await buildProviderSubmitHandler(path, original, null)(
59+
{
60+
...values,
61+
reasoningEfforts: ["low", "max"],
62+
defaultReasoningEffort: "max",
63+
contextWindow: "64000",
64+
maxTokens: "8192",
65+
temperature: "0",
66+
topP: "",
67+
},
68+
() => undefined,
69+
{ skipValidation: true },
70+
);
71+
const settings = await loadSettings(path);
72+
if (settings === null) throw new Error("missing edited settings");
73+
expect(Object.keys(settings.providers)).toEqual(["combined"]);
74+
expect(settings.providers["combined"]).toMatchObject({
75+
models: ["custom-only"],
76+
reasoningEfforts: ["low", "max"],
77+
defaultReasoningEffort: "max",
78+
contextWindow: 64_000,
79+
maxTokens: 8192,
80+
temperature: 0,
81+
});
82+
expect(settings.providers["combined"]?.topP).toBeUndefined();
83+
refreshProviderContextWindows(settings, catalog, values.name, values.model);
84+
expect(contextWindowFor("combined:custom-only")).toBe(64_000);
85+
86+
// Discovery can still hold the old declaration when an edited row is used.
87+
for (const reasoningEffort of [undefined, "medium"] as const) {
88+
const source = buildMainSessionSources({
89+
settings,
90+
catalog,
91+
activeProvider: values.name,
92+
activeModel: values.model,
93+
sessionId: "combined-session",
94+
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
95+
}).sources[0];
96+
if (source === undefined) throw new Error("missing source");
97+
const adapter = createOpenAICompatibleAdapter({
98+
sourceId: source.id,
99+
provider: source.provider,
100+
model: source.model,
101+
});
102+
const body = JSON.parse(
103+
adapter.buildRequest([], source.model, source.defaults ?? {}).body,
104+
) as Record<string, unknown>;
105+
expect(body.reasoning_effort).toBe("max");
106+
expect(body.max_tokens).toBe(8192);
107+
expect(body.temperature).toBe(0);
108+
expect(body).not.toHaveProperty("top_p");
109+
}
110+
} finally {
111+
await rm(dir, { recursive: true, force: true });
112+
}
113+
});

‎src/config/index.ts‎

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,12 +273,17 @@ export function buildOpenAISource(fields: {
273273
apiKey?: string;
274274
model: string;
275275
reasoningEffort?: ReasoningEffort;
276+
maxTokens?: number;
277+
temperature?: number;
278+
topP?: number;
276279
quirks?: Record<string, unknown>;
277280
}): InferenceSource {
278-
const overrides =
279-
fields.reasoningEffort !== undefined
280-
? { providerOptions: { reasoning_effort: fields.reasoningEffort } }
281-
: {};
281+
const providerOptions: Record<string, unknown> = {};
282+
if (fields.reasoningEffort !== undefined)
283+
providerOptions["reasoning_effort"] = fields.reasoningEffort;
284+
if (fields.temperature !== undefined)
285+
providerOptions["temperature"] = fields.temperature;
286+
if (fields.topP !== undefined) providerOptions["top_p"] = fields.topP;
282287
registerSourceSecret(fields.id, fields.apiKey);
283288
return {
284289
id: fields.id,
@@ -288,7 +293,10 @@ export function buildOpenAISource(fields: {
288293
: normalizeOpenAICompatibleBaseURL(fields.baseURL),
289294
credentialId: fields.id,
290295
model: fields.model,
291-
defaults: { maxTokens: SOURCE_MAX_TOKENS, ...overrides },
296+
defaults: {
297+
maxTokens: fields.maxTokens ?? SOURCE_MAX_TOKENS,
298+
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
299+
},
292300
...(fields.quirks !== undefined ? { quirks: fields.quirks } : {}),
293301
};
294302
}
@@ -1382,12 +1390,55 @@ export function mergeOAuthCatalog(
13821390
export async function refreshLiveProviderCatalog(
13831391
settings: Settings | null,
13841392
resolved: ResolvedProvider,
1393+
liveSelection?: () => Pick<ResolvedProvider, "providerName" | "model">,
13851394
): Promise<ProviderCatalogEntry[]> {
13861395
const [codexProfiles, xaiProfiles] = await Promise.all([
13871396
listCodexProfiles(),
13881397
listXaiProfiles(),
13891398
]);
1390-
return mergeOAuthCatalog(settings, resolved, codexProfiles, xaiProfiles);
1399+
const catalog = mergeOAuthCatalog(
1400+
settings,
1401+
resolved,
1402+
codexProfiles,
1403+
xaiProfiles,
1404+
);
1405+
// Discovery can finish after a model switch; never restore its old bare-model slot.
1406+
const active = liveSelection?.() ?? resolved;
1407+
refreshProviderContextWindows(
1408+
settings ?? undefined,
1409+
catalog,
1410+
active.providerName,
1411+
active.model,
1412+
);
1413+
return catalog;
1414+
}
1415+
1416+
export function refreshProviderContextWindows(
1417+
settings: Settings | undefined,
1418+
catalog: readonly ProviderCatalogEntry[],
1419+
activeProvider: string,
1420+
activeModel: string,
1421+
): void {
1422+
// Catalog models can expand during discovery. OAuth projections deliberately
1423+
// omit settings-only window overrides, just as startup resolution does.
1424+
const providers = Object.fromEntries(
1425+
catalog.map((entry) => {
1426+
const window =
1427+
entry.codexProfile === undefined && entry.xaiProfile === undefined
1428+
? settings?.providers[entry.name]?.contextWindow
1429+
: undefined;
1430+
return [
1431+
entry.name,
1432+
{
1433+
models: entry.models,
1434+
...(window !== undefined ? { contextWindow: window } : {}),
1435+
},
1436+
];
1437+
}),
1438+
);
1439+
setProviderContextWindowOverrides(
1440+
buildProviderContextWindowOverrides(providers, activeProvider, activeModel),
1441+
);
13911442
}
13921443

13931444
export function catalogEntryAsProviderSettings(
@@ -1413,6 +1464,11 @@ export function catalogEntryAsProviderSettings(
14131464
? { defaultModel: entry.defaultModel }
14141465
: {}),
14151466
...(entry.free !== undefined ? { free: entry.free } : {}),
1467+
...(entry.maxTokens !== undefined ? { maxTokens: entry.maxTokens } : {}),
1468+
...(entry.temperature !== undefined
1469+
? { temperature: entry.temperature }
1470+
: {}),
1471+
...(entry.topP !== undefined ? { topP: entry.topP } : {}),
14161472
...(entry.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}),
14171473
...(entry.anthropic === true ? { anthropic: true } : {}),
14181474
...(go ? { opencodeGo: true } : {}),
@@ -1497,6 +1553,11 @@ export function buildProviderCatalog(
14971553
? { defaultModel: p.defaultModel }
14981554
: {}),
14991555
...(p.free !== undefined ? { free: p.free } : {}),
1556+
...(p.maxTokens !== undefined ? { maxTokens: p.maxTokens } : {}),
1557+
...(p.temperature !== undefined
1558+
? { temperature: p.temperature }
1559+
: {}),
1560+
...(p.topP !== undefined ? { topP: p.topP } : {}),
15001561
...(p.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}),
15011562
...(p.anthropic === true ? { anthropic: true } : {}),
15021563
...(go ? { opencodeGo: true } : {}),

‎src/config/inference-sources.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,67 @@ describe("contextWindow / maxTokens split (CL-7784)", () => {
205205
});
206206
});
207207

208+
describe("custom provider token/sampling knobs flow into the source", () => {
209+
test("maxTokens/temperature/topP from provider settings reach defaults", () => {
210+
const entryCatalog: ProviderCatalogEntry[] = [
211+
{
212+
name: "custom",
213+
baseURL: "https://api.example.com/v1",
214+
apiKey: "sk-key",
215+
models: ["m"],
216+
},
217+
];
218+
const settings: Settings = {
219+
providers: {
220+
custom: {
221+
baseURL: "https://api.example.com/v1",
222+
apiKey: "sk-key",
223+
models: ["m"],
224+
maxTokens: 4096,
225+
temperature: 0.7,
226+
},
227+
},
228+
};
229+
const source = buildInferenceSourceForRef(
230+
{ provider: "custom", model: "m" },
231+
{ sessionId: "sess-1", catalog: entryCatalog },
232+
settings,
233+
);
234+
expect(source).not.toBeNull();
235+
expect(source?.defaults?.maxTokens).toBe(4096);
236+
expect(source?.defaults?.providerOptions).toEqual({ temperature: 0.7 });
237+
});
238+
239+
test("topP flows as top_p and overrides nothing else", () => {
240+
const entryCatalog: ProviderCatalogEntry[] = [
241+
{
242+
name: "custom",
243+
baseURL: "https://api.example.com/v1",
244+
apiKey: "sk-key",
245+
models: ["m"],
246+
topP: 0.9,
247+
},
248+
];
249+
const source = buildInferenceSourceForRef(
250+
{ provider: "custom", model: "m" },
251+
{ sessionId: "sess-1", catalog: entryCatalog },
252+
undefined,
253+
);
254+
expect(source?.defaults?.maxTokens).toBe(SOURCE_MAX_TOKENS);
255+
expect(source?.defaults?.providerOptions).toEqual({ top_p: 0.9 });
256+
});
257+
258+
test("unset knobs leave the default maxTokens and no providerOptions", () => {
259+
const source = buildInferenceSourceForRef(
260+
{ provider: "fp", model: "fp-large" },
261+
ctx(),
262+
undefined,
263+
);
264+
expect(source?.defaults?.maxTokens).toBe(SOURCE_MAX_TOKENS);
265+
expect(source?.defaults?.providerOptions).toBeUndefined();
266+
});
267+
});
268+
208269
describe("OpenAI reasoning max_completion_tokens quirk (CL-7785)", () => {
209270
const openaiApi = firstClassProviderById("openai")?.paths?.find(
210271
(p) => p.id === "api",

‎src/config/inference-sources.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ export function buildInferenceSourceForRef(
204204
reasoning?.defaultReasoningEffort,
205205
)
206206
: undefined;
207+
// A cleared sampling field must not revive its stale catalog value.
208+
const inferenceOptions = providerSettings ?? entry;
209+
const maxTokens = inferenceOptions?.maxTokens;
210+
const temperature = inferenceOptions?.temperature;
211+
const topP = inferenceOptions?.topP;
207212
return buildOpenAISource({
208213
id: ref.provider,
209214
baseURL,
@@ -214,6 +219,9 @@ export function buildInferenceSourceForRef(
214219
: {}),
215220
model: ref.model,
216221
...(customEffort !== undefined ? { reasoningEffort: customEffort } : {}),
222+
...(maxTokens !== undefined ? { maxTokens } : {}),
223+
...(temperature !== undefined ? { temperature } : {}),
224+
...(topP !== undefined ? { topP } : {}),
217225
...(quirks !== undefined ? { quirks } : {}),
218226
});
219227
}

0 commit comments

Comments
 (0)