fix: update the logic to update thinking in cli tools page

This commit is contained in:
2026-07-20 10:14:02 +07:00
parent 5329a4bd67
commit cb0807fe74
5 changed files with 170 additions and 97 deletions
@@ -67,7 +67,20 @@ const getDuplicateModelClass = ({ total, occurrence }) => (
total > 1 ? DUPLICATE_MODEL_COLORS[(occurrence - 1) % DUPLICATE_MODEL_COLORS.length] : "border-border bg-bg-secondary" total > 1 ? DUPLICATE_MODEL_COLORS[(occurrence - 1) % DUPLICATE_MODEL_COLORS.length] : "border-border bg-bg-secondary"
); );
function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "", opencodeModels = [], opencodeDefaultModel = "", coworkThinking = {}, copilotTokens = {}, copilotThinking = {}, connectedModels = [] }) { const createCoworkModelSettings = (models, savedSettings = [], legacyThinking = {}) => (
models.map((model, index) => ({
thinking: savedSettings[index]?.thinking || legacyThinking[model] || "",
}))
);
const createCopilotModelSettings = (models, savedSettings = [], legacyThinking = {}, legacyTokens = {}) => (
models.map((model, index) => ({
thinking: savedSettings[index]?.thinking || legacyThinking[model] || "",
tokens: savedSettings[index]?.tokens || legacyTokens[model] || DEFAULT_MODEL_TOKEN_LIMITS,
}))
);
function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "", opencodeModels = [], opencodeDefaultModel = "", coworkModelSettings = [], copilotModelSettings = [], connectedModels = [] }) {
const endpoint = normalizeV1(baseUrl); const endpoint = normalizeV1(baseUrl);
const selectedModels = models.length ? models : [DEFAULT_MODEL]; const selectedModels = models.length ? models : [DEFAULT_MODEL];
const model = selectedModels[0]; const model = selectedModels[0];
@@ -123,7 +136,7 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau
case "copilot": case "copilot":
return [{ return [{
filename: "chatLanguageModels.json", filename: "chatLanguageModels.json",
content: toJson(Object.values(selectedModels.reduce((groups, id) => { content: toJson(Object.values(selectedModels.reduce((groups, id, index) => {
const connectedModel = connectedModels.find((item) => item.fullModel === id); const connectedModel = connectedModels.find((item) => item.fullModel === id);
const providerId = connectedModel?.providerAlias || id.split("/")[0] || "9router"; const providerId = connectedModel?.providerAlias || id.split("/")[0] || "9router";
const providerName = connectedModel?.provider?.name || formatModelName(providerId); const providerName = connectedModel?.provider?.name || formatModelName(providerId);
@@ -136,8 +149,9 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau
apiKey: COPILOT_API_KEY_INPUT, apiKey: COPILOT_API_KEY_INPUT,
models: [], models: [],
}; };
const tokens = copilotTokens[id] || {}; const modelSettings = copilotModelSettings[index] || {};
const modelId = withThinkingLevel(id, copilotThinking[id]); const tokens = modelSettings.tokens || {};
const modelId = withThinkingLevel(id, modelSettings.thinking);
const entry = { const entry = {
id: modelId, id: modelId,
name: formatModelName(modelId), name: formatModelName(modelId),
@@ -146,7 +160,7 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau
vision: true, vision: true,
streaming: true, streaming: true,
}; };
if (copilotThinking[id]) { if (modelSettings.thinking) {
entry.thinking = true; entry.thinking = true;
entry.reasoningEffortFormat = "chat-completions"; entry.reasoningEffortFormat = "chat-completions";
} }
@@ -168,8 +182,8 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau
inferenceProvider: "gateway", inferenceProvider: "gateway",
inferenceGatewayBaseUrl: endpoint, inferenceGatewayBaseUrl: endpoint,
inferenceGatewayApiKey: apiKey, inferenceGatewayApiKey: apiKey,
inferenceModels: selectedModels.map((name) => ({ inferenceModels: selectedModels.map((name, index) => ({
name: withThinkingLevel(name, coworkThinking[name]), name: withThinkingLevel(name, coworkModelSettings[index]?.thinking),
})), })),
}), }),
}]; }];
@@ -211,9 +225,17 @@ export default function ConfigGeneratorCard({
const [codexThinking, setCodexThinking] = useState(() => initialConfig?.codexThinking || ""); const [codexThinking, setCodexThinking] = useState(() => initialConfig?.codexThinking || "");
const [opencodeModels, setOpencodeModels] = useState(() => initialConfig?.opencodeModels || []); const [opencodeModels, setOpencodeModels] = useState(() => initialConfig?.opencodeModels || []);
const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(() => initialConfig?.opencodeDefaultModel || ""); const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(() => initialConfig?.opencodeDefaultModel || "");
const [coworkThinking, setCoworkThinking] = useState(() => initialConfig?.coworkThinking || {}); const [coworkModelSettings, setCoworkModelSettings] = useState(() => createCoworkModelSettings(
const [copilotTokens, setCopilotTokens] = useState(() => initialConfig?.copilotTokens || {}); initialConfig?.selectedModels || [],
const [copilotThinking, setCopilotThinking] = useState(() => initialConfig?.copilotThinking || {}); initialConfig?.coworkModelSettings,
initialConfig?.coworkThinking,
));
const [copilotModelSettings, setCopilotModelSettings] = useState(() => createCopilotModelSettings(
initialConfig?.selectedModels || [],
initialConfig?.copilotModelSettings,
initialConfig?.copilotThinking,
initialConfig?.copilotTokens,
));
const connectedModels = availableModels; const connectedModels = availableModels;
const [customBaseUrl, setCustomBaseUrl] = useState(() => resolveInitialCliToolBaseUrl(initialConfig?.baseUrl, baseUrl)); const [customBaseUrl, setCustomBaseUrl] = useState(() => resolveInitialCliToolBaseUrl(initialConfig?.baseUrl, baseUrl));
const [modelModalOpen, setModelModalOpen] = useState(false); const [modelModalOpen, setModelModalOpen] = useState(false);
@@ -227,8 +249,8 @@ export default function ConfigGeneratorCard({
? COPILOT_API_KEY_INPUT ? COPILOT_API_KEY_INPUT
: selectedApiKey.trim() || (cloudEnabled ? "<API_KEY_FROM_DASHBOARD>" : "sk_9router"); : selectedApiKey.trim() || (cloudEnabled ? "<API_KEY_FROM_DASHBOARD>" : "sk_9router");
const configs = useMemo( const configs = useMemo(
() => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels: connectedModels || [] }), () => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkModelSettings, copilotModelSettings, connectedModels: connectedModels || [] }),
[toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels] [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkModelSettings, copilotModelSettings, connectedModels]
); );
const buildPersistableConfig = () => { const buildPersistableConfig = () => {
@@ -242,8 +264,8 @@ export default function ConfigGeneratorCard({
if (toolId === "claude") Object.assign(config, { claudeModels, claudeThinking }); if (toolId === "claude") Object.assign(config, { claudeModels, claudeThinking });
if (toolId === "codex") Object.assign(config, { codexModel, codexThinking }); if (toolId === "codex") Object.assign(config, { codexModel, codexThinking });
if (toolId === "opencode") Object.assign(config, { opencodeModels, opencodeDefaultModel }); if (toolId === "opencode") Object.assign(config, { opencodeModels, opencodeDefaultModel });
if (toolId === "cowork") Object.assign(config, { selectedModels, coworkThinking }); if (toolId === "cowork") Object.assign(config, { selectedModels, coworkModelSettings });
if (toolId === "copilot") Object.assign(config, { selectedModels, copilotThinking, copilotTokens }); if (toolId === "copilot") Object.assign(config, { selectedModels, copilotModelSettings });
return config; return config;
}; };
@@ -266,7 +288,7 @@ export default function ConfigGeneratorCard({
} }
setSaveStatus((current) => current === "saving" ? current : "dirty"); setSaveStatus((current) => current === "saving" ? current : "dirty");
setSaveError(""); setSaveError("");
}, [effectiveBaseUrl, apiKeyMode, selectedApiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking]); }, [effectiveBaseUrl, apiKeyMode, selectedApiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkModelSettings, copilotModelSettings]);
const getThinkingLevelsForModel = (fullModel) => { const getThinkingLevelsForModel = (fullModel) => {
const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel); const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel);
@@ -274,7 +296,7 @@ export default function ConfigGeneratorCard({
return getThinkingLevels(connectedModel.provider.id, connectedModel.model); return getThinkingLevels(connectedModel.provider.id, connectedModel.model);
}; };
const loadCopilotTokenLimits = async (modelIds) => { const loadCopilotTokenLimits = async (modelIds, modelsForSettings = selectedModels) => {
try { try {
const response = await fetch("/api/models/token-limits", { const response = await fetch("/api/models/token-limits", {
method: "POST", method: "POST",
@@ -284,14 +306,16 @@ export default function ConfigGeneratorCard({
if (!response.ok) throw new Error("Failed to load models.dev token limits"); if (!response.ok) throw new Error("Failed to load models.dev token limits");
const { limits = {} } = await response.json(); const { limits = {} } = await response.json();
setCopilotTokens((current) => Object.entries(limits).reduce((next, [model, limit]) => { setCopilotModelSettings((current) => current.map((settings, index) => {
const currentLimit = current[model]; const model = modelsForSettings[index];
const limit = limits[model];
if (!limit) return settings;
const currentLimit = settings.tokens;
const hasUserOverride = currentLimit const hasUserOverride = currentLimit
&& (currentLimit.maxInputTokens !== DEFAULT_MODEL_TOKEN_LIMITS.maxInputTokens && (currentLimit.maxInputTokens !== DEFAULT_MODEL_TOKEN_LIMITS.maxInputTokens
|| currentLimit.maxOutputTokens !== DEFAULT_MODEL_TOKEN_LIMITS.maxOutputTokens); || currentLimit.maxOutputTokens !== DEFAULT_MODEL_TOKEN_LIMITS.maxOutputTokens);
next[model] = hasUserOverride ? currentLimit : limit; return { ...settings, tokens: hasUserOverride ? currentLimit : limit };
return next; }));
}, { ...current }));
} catch (error) { } catch (error) {
console.log("Error loading models.dev token limits:", error); console.log("Error loading models.dev token limits:", error);
} }
@@ -299,42 +323,23 @@ export default function ConfigGeneratorCard({
const addModel = (selected) => { const addModel = (selected) => {
if (!selected?.value) return; if (!selected?.value) return;
setSelectedModels((current) => [...current, selected.value]); const nextModels = [...selectedModels, selected.value];
if (toolId === "cowork") setCoworkThinking((current) => ({ ...current, [selected.value]: "" })); setSelectedModels(nextModels);
if (toolId === "cowork") setCoworkModelSettings((current) => [...current, { thinking: "" }]);
if (toolId === "copilot") { if (toolId === "copilot") {
setCopilotThinking((current) => ({ ...current, [selected.value]: "" })); setCopilotModelSettings((current) => [...current, { thinking: "", tokens: DEFAULT_MODEL_TOKEN_LIMITS }]);
setCopilotTokens((current) => ({ ...current, [selected.value]: DEFAULT_MODEL_TOKEN_LIMITS })); loadCopilotTokenLimits([selected.value], nextModels);
loadCopilotTokenLimits([selected.value]);
} }
}; };
const removeCoworkModel = (model, index) => { const removeCoworkModel = (index) => {
const remainingModels = selectedModels.filter((_, currentIndex) => currentIndex !== index); setSelectedModels((current) => current.filter((_, currentIndex) => currentIndex !== index));
setSelectedModels(remainingModels); setCoworkModelSettings((current) => current.filter((_, currentIndex) => currentIndex !== index));
if (!remainingModels.includes(model)) {
setCoworkThinking((current) => {
const remainingThinking = { ...current };
delete remainingThinking[model];
return remainingThinking;
});
}
}; };
const removeCopilotModel = (model, index) => { const removeCopilotModel = (index) => {
const remainingModels = selectedModels.filter((_, currentIndex) => currentIndex !== index); setSelectedModels((current) => current.filter((_, currentIndex) => currentIndex !== index));
setSelectedModels(remainingModels); setCopilotModelSettings((current) => current.filter((_, currentIndex) => currentIndex !== index));
if (!remainingModels.includes(model)) {
setCopilotThinking((current) => {
const remaining = { ...current };
delete remaining[model];
return remaining;
});
setCopilotTokens((current) => {
const remaining = { ...current };
delete remaining[model];
return remaining;
});
}
}; };
const selectClaudeModel = (selected) => { const selectClaudeModel = (selected) => {
@@ -530,8 +535,10 @@ export default function ConfigGeneratorCard({
<label className="flex items-center gap-2 text-xs font-medium text-text-muted"> <label className="flex items-center gap-2 text-xs font-medium text-text-muted">
Reasoning / thinking Reasoning / thinking
<select <select
value={coworkThinking[model] || ""} value={coworkModelSettings[index]?.thinking || ""}
onChange={(event) => setCoworkThinking((current) => ({ ...current, [model]: event.target.value }))} onChange={(event) => setCoworkModelSettings((current) => current.map((settings, currentIndex) => (
currentIndex === index ? { ...settings, thinking: event.target.value } : settings
)))}
className="min-w-28 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary" className="min-w-28 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
> >
<option value="">Default</option> <option value="">Default</option>
@@ -539,7 +546,7 @@ export default function ConfigGeneratorCard({
</select> </select>
</label> </label>
)} )}
<Button type="button" variant="ghost" size="sm" onClick={() => removeCoworkModel(model, index)} aria-label={`Remove ${model} instance ${index + 1}`}>Remove</Button> <Button type="button" variant="ghost" size="sm" onClick={() => removeCoworkModel(index)} aria-label={`Remove ${model} instance ${index + 1}`}>Remove</Button>
</div> </div>
); );
})} })}
@@ -560,7 +567,8 @@ export default function ConfigGeneratorCard({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{selectedModels.map((model, index) => { {selectedModels.map((model, index) => {
const thinkingLevels = getThinkingLevelsForModel(model); const thinkingLevels = getThinkingLevelsForModel(model);
const tokens = copilotTokens[model] || DEFAULT_MODEL_TOKEN_LIMITS; const modelSettings = copilotModelSettings[index] || {};
const tokens = modelSettings.tokens || DEFAULT_MODEL_TOKEN_LIMITS;
const inputOptions = getInputTokenOptions(tokens); const inputOptions = getInputTokenOptions(tokens);
const outputOptions = getOutputTokenOptions(tokens); const outputOptions = getOutputTokenOptions(tokens);
const occurrence = getModelOccurrence(selectedModels, model, index); const occurrence = getModelOccurrence(selectedModels, model, index);
@@ -568,14 +576,16 @@ export default function ConfigGeneratorCard({
<div key={`${model}-${index}`} className={`flex flex-col gap-2 rounded-lg border px-3 py-2 ${getDuplicateModelClass(occurrence)}`}> <div key={`${model}-${index}`} className={`flex flex-col gap-2 rounded-lg border px-3 py-2 ${getDuplicateModelClass(occurrence)}`}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 break-all text-xs font-medium text-text-main">{model}{occurrence.total > 1 && <span className="ml-1 text-[10px] font-bold opacity-70">#{occurrence.occurrence}</span>}</span> <span className="min-w-0 flex-1 break-all text-xs font-medium text-text-main">{model}{occurrence.total > 1 && <span className="ml-1 text-[10px] font-bold opacity-70">#{occurrence.occurrence}</span>}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => removeCopilotModel(model, index)} aria-label={`Remove ${model} instance ${index + 1}`}>Remove</Button> <Button type="button" variant="ghost" size="sm" onClick={() => removeCopilotModel(index)} aria-label={`Remove ${model} instance ${index + 1}`}>Remove</Button>
</div> </div>
{thinkingLevels && ( {thinkingLevels && (
<label className="flex items-center gap-2 text-xs font-medium text-text-muted"> <label className="flex items-center gap-2 text-xs font-medium text-text-muted">
Reasoning / thinking Reasoning / thinking
<select <select
value={copilotThinking[model] || ""} value={modelSettings.thinking || ""}
onChange={(event) => setCopilotThinking((current) => ({ ...current, [model]: event.target.value }))} onChange={(event) => setCopilotModelSettings((current) => current.map((settings, currentIndex) => (
currentIndex === index ? { ...settings, thinking: event.target.value } : settings
)))}
className="min-w-28 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary" className="min-w-28 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
> >
<option value="">Default</option> <option value="">Default</option>
@@ -588,10 +598,11 @@ export default function ConfigGeneratorCard({
maxInputTokens maxInputTokens
<select <select
value={tokens.maxInputTokens || ""} value={tokens.maxInputTokens || ""}
onChange={(event) => setCopilotTokens((current) => ({ onChange={(event) => setCopilotModelSettings((current) => current.map((settings, currentIndex) => (
...current, currentIndex === index
[model]: { ...current[model], maxInputTokens: event.target.value ? Number(event.target.value) : undefined }, ? { ...settings, tokens: { ...settings.tokens, maxInputTokens: event.target.value ? Number(event.target.value) : undefined } }
}))} : settings
)))}
className="min-w-20 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary" className="min-w-20 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
> >
{inputOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)} {inputOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
@@ -601,10 +612,11 @@ export default function ConfigGeneratorCard({
maxOutputTokens maxOutputTokens
<select <select
value={tokens.maxOutputTokens || ""} value={tokens.maxOutputTokens || ""}
onChange={(event) => setCopilotTokens((current) => ({ onChange={(event) => setCopilotModelSettings((current) => current.map((settings, currentIndex) => (
...current, currentIndex === index
[model]: { ...current[model], maxOutputTokens: event.target.value ? Number(event.target.value) : undefined }, ? { ...settings, tokens: { ...settings.tokens, maxOutputTokens: event.target.value ? Number(event.target.value) : undefined } }
}))} : settings
)))}
className="min-w-20 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary" className="min-w-20 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
> >
{outputOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)} {outputOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
+8 -3
View File
@@ -188,9 +188,14 @@ function cleanCliToolConfigsSync(db, providerAliases, modelId) {
for (const field of ["opencodeModels", "selectedModels"]) { for (const field of ["opencodeModels", "selectedModels"]) {
if (!Array.isArray(config[field])) continue; if (!Array.isArray(config[field])) continue;
const removedModels = config[field].filter(deleted); const removedIndexes = config[field].flatMap((reference, index) => (deleted(reference) ? [index] : []));
if (removedModels.length === 0) continue; if (removedIndexes.length === 0) continue;
config[field] = config[field].filter((reference) => !deleted(reference)); const removedModels = removedIndexes.map((index) => config[field][index]);
config[field] = config[field].filter((_, index) => !removedIndexes.includes(index));
for (const settingsField of ["coworkModelSettings", "copilotModelSettings"]) {
if (!Array.isArray(config[settingsField])) continue;
config[settingsField] = config[settingsField].filter((_, index) => !removedIndexes.includes(index));
}
for (const mapField of ["coworkThinking", "copilotThinking", "copilotTokens"]) { for (const mapField of ["coworkThinking", "copilotThinking", "copilotTokens"]) {
if (!config[mapField] || typeof config[mapField] !== "object") continue; if (!config[mapField] || typeof config[mapField] !== "object") continue;
const nextMap = { ...config[mapField] }; const nextMap = { ...config[mapField] };
+52 -24
View File
@@ -89,6 +89,22 @@ function normalizeThinkingMap(value, allowedKeys, field) {
return normalized; return normalized;
} }
function normalizeTokenLimits(value, field) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new CliToolConfigValidationError(`${field} must be an object`);
}
const normalized = {};
for (const tokenField of ["maxInputTokens", "maxOutputTokens"]) {
if (value[tokenField] === undefined || value[tokenField] === null || value[tokenField] === "") continue;
const number = Number(value[tokenField]);
if (!Number.isSafeInteger(number) || number <= 0 || number > MAX_TOKEN_LIMIT) {
throw new CliToolConfigValidationError(`${tokenField} must be a positive integer no greater than ${MAX_TOKEN_LIMIT}`);
}
normalized[tokenField] = number;
}
return normalized;
}
function normalizeApiKeyReference(input) { function normalizeApiKeyReference(input) {
const mode = input.apiKeyMode === undefined ? "managed" : input.apiKeyMode; const mode = input.apiKeyMode === undefined ? "managed" : input.apiKeyMode;
if (!['managed', 'custom'].includes(mode)) { if (!['managed', 'custom'].includes(mode)) {
@@ -98,30 +114,43 @@ function normalizeApiKeyReference(input) {
return { apiKeyMode: mode, apiKeyId: mode === "managed" ? apiKeyId : null }; return { apiKeyMode: mode, apiKeyId: mode === "managed" ? apiKeyId : null };
} }
function normalizeTokenMap(value, selectedModels) { function normalizeCoworkModelSettings(value, selectedModels, legacyThinking) {
if (value === undefined) return {}; if (value !== undefined && !Array.isArray(value)) {
if (!value || typeof value !== "object" || Array.isArray(value)) { throw new CliToolConfigValidationError("coworkModelSettings must be an array");
}
const legacySettings = normalizeThinkingMap(legacyThinking, selectedModels, "coworkThinking");
return selectedModels.map((model, index) => {
const setting = value?.[index];
if (setting !== undefined && (!setting || typeof setting !== "object" || Array.isArray(setting))) {
throw new CliToolConfigValidationError(`coworkModelSettings.${index} must be an object`);
}
const thinking = normalizeThinking(setting?.thinking ?? legacySettings[model], `coworkModelSettings.${index}.thinking`);
return thinking ? { thinking } : {};
});
}
function normalizeCopilotModelSettings(value, selectedModels, legacyThinking, legacyTokens) {
if (value !== undefined && !Array.isArray(value)) {
throw new CliToolConfigValidationError("copilotModelSettings must be an array");
}
const legacyThinkingSettings = normalizeThinkingMap(legacyThinking, selectedModels, "copilotThinking");
const legacyTokenSettings = legacyTokens === undefined ? {} : legacyTokens;
if (!legacyTokenSettings || typeof legacyTokenSettings !== "object" || Array.isArray(legacyTokenSettings)) {
throw new CliToolConfigValidationError("copilotTokens must be an object"); throw new CliToolConfigValidationError("copilotTokens must be an object");
} }
const normalized = {}; return selectedModels.map((model, index) => {
for (const model of selectedModels) { const setting = value?.[index];
const limits = value[model]; if (setting !== undefined && (!setting || typeof setting !== "object" || Array.isArray(setting))) {
if (limits === undefined) continue; throw new CliToolConfigValidationError(`copilotModelSettings.${index} must be an object`);
if (!limits || typeof limits !== "object" || Array.isArray(limits)) {
throw new CliToolConfigValidationError(`copilotTokens.${model} must be an object`);
} }
const next = {}; const thinking = normalizeThinking(setting?.thinking ?? legacyThinkingSettings[model], `copilotModelSettings.${index}.thinking`);
for (const field of ["maxInputTokens", "maxOutputTokens"]) { const tokensSource = setting?.tokens ?? legacyTokenSettings[model];
if (limits[field] === undefined || limits[field] === null || limits[field] === "") continue; const tokens = tokensSource === undefined ? {} : normalizeTokenLimits(tokensSource, `copilotModelSettings.${index}.tokens`);
const number = Number(limits[field]); return {
if (!Number.isSafeInteger(number) || number <= 0 || number > MAX_TOKEN_LIMIT) { ...(thinking ? { thinking } : {}),
throw new CliToolConfigValidationError(`${field} must be a positive integer no greater than ${MAX_TOKEN_LIMIT}`); ...(Object.keys(tokens).length ? { tokens } : {}),
} };
next[field] = number; });
}
if (Object.keys(next).length) normalized[model] = next;
}
return normalized;
} }
export function normalizeCliToolConfig(toolId, input) { export function normalizeCliToolConfig(toolId, input) {
@@ -155,13 +184,12 @@ export function normalizeCliToolConfig(toolId, input) {
: (config.opencodeModels[0] || ""); : (config.opencodeModels[0] || "");
} else if (toolId === "cowork") { } else if (toolId === "cowork") {
config.selectedModels = normalizeModels(input.selectedModels); config.selectedModels = normalizeModels(input.selectedModels);
config.coworkThinking = normalizeThinkingMap(input.coworkThinking, config.selectedModels, "coworkThinking"); config.coworkModelSettings = normalizeCoworkModelSettings(input.coworkModelSettings, config.selectedModels, input.coworkThinking);
} else if (toolId === "cursor") { } else if (toolId === "cursor") {
config.selectedModels = normalizeModels(input.selectedModels); config.selectedModels = normalizeModels(input.selectedModels);
} else if (toolId === "copilot") { } else if (toolId === "copilot") {
config.selectedModels = normalizeModels(input.selectedModels); config.selectedModels = normalizeModels(input.selectedModels);
config.copilotThinking = normalizeThinkingMap(input.copilotThinking, config.selectedModels, "copilotThinking"); config.copilotModelSettings = normalizeCopilotModelSettings(input.copilotModelSettings, config.selectedModels, input.copilotThinking, input.copilotTokens);
config.copilotTokens = normalizeTokenMap(input.copilotTokens, config.selectedModels);
} }
return config; return config;
+31 -3
View File
@@ -54,7 +54,10 @@ describe("CLI tool configuration contract", () => {
apiKeyMode: "managed", apiKeyMode: "managed",
selectedModels: ["cc/a", "cc/a"], selectedModels: ["cc/a", "cc/a"],
coworkThinking: { "cc/a": "high", "stale/model": "low" }, coworkThinking: { "cc/a": "high", "stale/model": "low" },
})).toMatchObject({ selectedModels: ["cc/a", "cc/a"], coworkThinking: { "cc/a": "high" } }); })).toMatchObject({
selectedModels: ["cc/a", "cc/a"],
coworkModelSettings: [{ thinking: "high" }, { thinking: "high" }],
});
expect(normalizeCliToolConfig("cursor", { expect(normalizeCliToolConfig("cursor", {
apiKeyMode: "managed", apiKeyMode: "managed",
@@ -73,11 +76,36 @@ describe("CLI tool configuration contract", () => {
})).toEqual({ })).toEqual({
baseUrl: "https://router.example/v1", baseUrl: "https://router.example/v1",
selectedModels: ["cc/a", "cc/a"], selectedModels: ["cc/a", "cc/a"],
copilotThinking: { "cc/a": "high" }, copilotModelSettings: [
copilotTokens: { "cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 } }, { thinking: "high", tokens: { maxInputTokens: 100000, maxOutputTokens: 32000 } },
{ thinking: "high", tokens: { maxInputTokens: 100000, maxOutputTokens: 32000 } },
],
}); });
}); });
it("keeps duplicate model settings independent by occurrence", () => {
expect(normalizeCliToolConfig("copilot", {
baseUrl: "https://router.example/v1",
selectedModels: ["cc/a", "cc/a"],
copilotModelSettings: [
{ thinking: "low", tokens: { maxInputTokens: 16000 } },
{ thinking: "high", tokens: { maxOutputTokens: 32000 } },
],
})).toMatchObject({
copilotModelSettings: [
{ thinking: "low", tokens: { maxInputTokens: 16000 } },
{ thinking: "high", tokens: { maxOutputTokens: 32000 } },
],
});
expect(normalizeCliToolConfig("cowork", {
baseUrl: "https://router.example",
apiKeyMode: "managed",
selectedModels: ["cc/a", "cc/a"],
coworkModelSettings: [{ thinking: "low" }, { thinking: "high" }],
})).toMatchObject({ coworkModelSettings: [{ thinking: "low" }, { thinking: "high" }] });
});
it("rejects plaintext secrets, invalid URLs, and invalid token limits", () => { it("rejects plaintext secrets, invalid URLs, and invalid token limits", () => {
expect(() => normalizeCliToolConfig("claude", { expect(() => normalizeCliToolConfig("claude", {
baseUrl: "https://router.example", baseUrl: "https://router.example",
+1 -1
View File
@@ -161,7 +161,7 @@ describe("permanent model deletion", () => {
}); });
expect((await db.getCliToolConfig(cliUser.id, "cowork")).config).toMatchObject({ expect((await db.getCliToolConfig(cliUser.id, "cowork")).config).toMatchObject({
selectedModels: [`${providerPrefix}/gpt-keep`], selectedModels: [`${providerPrefix}/gpt-keep`],
coworkThinking: { [`${providerPrefix}/gpt-keep`]: "low" }, coworkModelSettings: [{ thinking: "low" }],
}); });
const history = await db.getUsageHistory({}); const history = await db.getUsageHistory({});