Skip to content

Commit f4dfc0b

Browse files
Merge pull request #1307 from corbitsdev/cl-9833-let-custom-openai-compatible-providers-configure-reasoning
feat(provider): let custom providers declare reasoning effort levels
2 parents 0f7e430 + 6adf4d3 commit f4dfc0b

26 files changed

Lines changed: 1534 additions & 30 deletions
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+
});
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { afterEach, expect, test } from "bun:test";
2+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { loadConfig } from "./index.js";
6+
import { isSettings } from "./settings.js";
7+
import { clearSourceCredentials } from "./source-credentials.js";
8+
import {
9+
resetPricingMetadataRefreshForTests,
10+
applyPricingCacheMetadata,
11+
} from "../cost/pricing-metadata.js";
12+
import { setProviderContextWindowOverrides } from "../provider/context-window.js";
13+
import { buildSessionSourcesFromConfig } from "../session/runtime-assembly.js";
14+
import { applyLiveModelSwitch } from "../session/live-model-switch.js";
15+
import { generateSessionId, initSessionDir } from "../session/index.js";
16+
import { saveState } from "../session/state.js";
17+
import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-adapter.js";
18+
19+
const directories: string[] = [];
20+
21+
afterEach(async () => {
22+
resetPricingMetadataRefreshForTests();
23+
applyPricingCacheMetadata(null);
24+
setProviderContextWindowOverrides(undefined);
25+
clearSourceCredentials();
26+
for (const dir of directories.splice(0)) {
27+
await rm(dir, { recursive: true, force: true });
28+
}
29+
});
30+
31+
test("config accepts a custom-only local pin and rejects a disabled family rung", async () => {
32+
const cwd = await mkdtemp(join(tmpdir(), "custom-reasoning-config-"));
33+
directories.push(cwd);
34+
const globalSettingsPath = join(cwd, "global.json");
35+
await mkdir(join(cwd, ".corbits"));
36+
await writeFile(
37+
globalSettingsPath,
38+
JSON.stringify({
39+
providers: {
40+
custom: {
41+
baseURL: "https://custom.example/v1",
42+
keyless: true,
43+
models: ["custom-model"],
44+
reasoningEfforts: ["low", "xhigh", "max"],
45+
defaultReasoningEffort: "max",
46+
},
47+
},
48+
}),
49+
);
50+
const localPath = join(cwd, ".corbits", "settings.json");
51+
const options = {
52+
globalSettingsPath,
53+
home: cwd,
54+
pricing: {
55+
cachePath: join(cwd, "pricing.json"),
56+
fetchImpl: (() =>
57+
Promise.reject(new Error("offline"))) as unknown as typeof fetch,
58+
},
59+
};
60+
await writeFile(localPath, JSON.stringify({ reasoningEffort: "xhigh" }));
61+
const config = await loadConfig(["--cwd", cwd], options);
62+
expect(config.configured).toBe(true);
63+
if (!config.configured) throw new Error("expected config");
64+
expect(config.reasoningEffort).toBe("xhigh");
65+
await writeFile(localPath, JSON.stringify({ reasoningEffort: "medium" }));
66+
await expect(loadConfig(["--cwd", cwd], options)).rejects.toThrow(
67+
/supported: low, xhigh, max/,
68+
);
69+
});
70+
71+
test("settings validate nonempty canonical ladders and enabled defaults", () => {
72+
const provider = {
73+
baseURL: "https://custom.example/v1",
74+
keyless: true,
75+
models: ["custom-model"],
76+
};
77+
const allows = (reasoning: Record<string, unknown>) =>
78+
isSettings({ providers: { custom: { ...provider, ...reasoning } } });
79+
expect(allows({})).toBe(true);
80+
expect(
81+
allows({ reasoningEfforts: ["low", "max"], defaultReasoningEffort: "max" }),
82+
).toBe(true);
83+
expect(allows({ reasoningEfforts: [] })).toBe(false);
84+
expect(allows({ reasoningEfforts: ["bogus"] })).toBe(false);
85+
expect(
86+
allows({ reasoningEfforts: ["low"], defaultReasoningEffort: "max" }),
87+
).toBe(false);
88+
expect(allows({ defaultReasoningEffort: "max" })).toBe(false);
89+
});
90+
91+
test("startup, resume, and live model switch serialize their custom defaults", async () => {
92+
const cwd = await mkdtemp(join(tmpdir(), "custom-reasoning-session-"));
93+
directories.push(cwd);
94+
const globalSettingsPath = join(cwd, "global.json");
95+
const custom = {
96+
baseURL: "https://custom.example/v1",
97+
keyless: true,
98+
models: ["custom-model"],
99+
reasoningEfforts: ["low", "max"],
100+
defaultReasoningEffort: "max",
101+
};
102+
await writeFile(
103+
globalSettingsPath,
104+
JSON.stringify({
105+
defaultProvider: "custom",
106+
providers: {
107+
custom,
108+
other: {
109+
...custom,
110+
reasoningEfforts: ["high", "xhigh"],
111+
defaultReasoningEffort: "xhigh",
112+
},
113+
},
114+
}),
115+
);
116+
const options = {
117+
globalSettingsPath,
118+
home: cwd,
119+
pricing: {
120+
cachePath: join(cwd, "pricing.json"),
121+
fetchImpl: (() =>
122+
Promise.reject(new Error("offline"))) as unknown as typeof fetch,
123+
},
124+
};
125+
const sessionId = generateSessionId();
126+
await initSessionDir(cwd, sessionId, cwd);
127+
await saveState(
128+
cwd,
129+
sessionId,
130+
{
131+
status: "done",
132+
turnsUsed: 1,
133+
task: "inspect",
134+
startedAt: 1,
135+
finishedAt: 2,
136+
},
137+
cwd,
138+
);
139+
for (const args of [[], ["resume", sessionId]]) {
140+
const loaded = await loadConfig([...args, "--cwd", cwd], options);
141+
if (!loaded.configured) throw new Error("expected config");
142+
let config = loaded;
143+
const effortOnWire = () => {
144+
const source = buildSessionSourcesFromConfig(config, config.sessionId)
145+
.sources[0];
146+
if (source === undefined) throw new Error("missing source");
147+
const adapter = createOpenAICompatibleAdapter({
148+
sourceId: source.id,
149+
provider: source.provider,
150+
model: source.model,
151+
});
152+
return (
153+
JSON.parse(
154+
adapter.buildRequest([], source.model, source.defaults ?? {}).body,
155+
) as Record<string, unknown>
156+
).reasoning_effort;
157+
};
158+
expect(effortOnWire()).toBe("max");
159+
let switchedEffort: unknown;
160+
applyLiveModelSwitch(
161+
{ providerName: "other", model: "custom-model" },
162+
{
163+
applyIdentity: (next) => {
164+
config = { ...config, ...next };
165+
},
166+
setPermissionIdentity: () => undefined,
167+
rebuildInference: () => {
168+
switchedEffort = effortOnWire();
169+
},
170+
refreshAdvertisedSchemas: () => undefined,
171+
},
172+
);
173+
expect(switchedEffort).toBe("xhigh");
174+
}
175+
});

‎src/config/index.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
xaiProvidersAsSettings,
4646
} from "./xai-providers.js";
4747
import { fetchBifrostModels } from "./bifrost.js";
48+
import { customReasoningSettings } from "./providers.js";
4849

4950
export { fetchBifrostModels };
5051
import { CODEX_BASE_URL } from "../auth/codex/constants.js";
@@ -1150,6 +1151,17 @@ export async function loadConfig(
11501151
),
11511152
);
11521153

1154+
const providers = mergeOAuthCatalog(
1155+
settings,
1156+
resolved,
1157+
codexProfiles,
1158+
xaiProfiles,
1159+
);
1160+
const reasoning = customReasoningSettings(
1161+
resolved.providerName,
1162+
settingsForResolution?.providers[resolved.providerName],
1163+
providers.find((entry) => entry.name === resolved.providerName),
1164+
);
11531165
// Enforce model/effort compatibility at the boundary. The modal only offers
11541166
// supported levels, but a hand-edited local settings file can pair an effort
11551167
// with a model that does not accept it; reject it here rather than shipping an
@@ -1159,6 +1171,7 @@ export async function loadConfig(
11591171
resolved.model,
11601172
local.reasoningEffort,
11611173
isCodexProviderName(resolved.providerName),
1174+
reasoning?.reasoningEfforts,
11621175
);
11631176
if (!verdict.ok) {
11641177
throw new Error(
@@ -1230,12 +1243,7 @@ export async function loadConfig(
12301243
...(settings?.defaultProvider !== undefined
12311244
? { globalDefaultProvider: settings.defaultProvider }
12321245
: {}),
1233-
providers: mergeOAuthCatalog(
1234-
settings,
1235-
resolved,
1236-
codexProfiles,
1237-
xaiProfiles,
1238-
),
1246+
providers,
12391247
...(profile.profile !== undefined ? { profile: profile.profile } : {}),
12401248
...(profile.systemPromptExtensions !== undefined
12411249
? { systemPromptExtensions: profile.systemPromptExtensions }
@@ -1465,6 +1473,13 @@ export function catalogEntryAsProviderSettings(
14651473
...(entry.anthropic === true ? { anthropic: true } : {}),
14661474
...(go ? { opencodeGo: true } : {}),
14671475
...(entry.verified === false ? { verified: false } : {}),
1476+
...(entry.reasoningEfforts !== undefined &&
1477+
entry.reasoningEfforts.length > 0
1478+
? { reasoningEfforts: entry.reasoningEfforts }
1479+
: {}),
1480+
...(entry.defaultReasoningEffort !== undefined
1481+
? { defaultReasoningEffort: entry.defaultReasoningEffort }
1482+
: {}),
14681483
};
14691484
}
14701485

@@ -1547,6 +1562,12 @@ export function buildProviderCatalog(
15471562
...(p.anthropic === true ? { anthropic: true } : {}),
15481563
...(go ? { opencodeGo: true } : {}),
15491564
...(p.verified === false ? { verified: false } : {}),
1565+
...(p.reasoningEfforts !== undefined && p.reasoningEfforts.length > 0
1566+
? { reasoningEfforts: p.reasoningEfforts }
1567+
: {}),
1568+
...(p.defaultReasoningEffort !== undefined
1569+
? { defaultReasoningEffort: p.defaultReasoningEffort }
1570+
: {}),
15501571
};
15511572
},
15521573
);

0 commit comments

Comments
 (0)