Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions scripts/v3-agent-profiles/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ assert.deepEqual(modelFromUpstream('codex', advertised).thinking.options, ['low'
assert.equal(modelFromUpstream('codex', advertised).thinking.selected, null);
assert.deepEqual(modelFromUpstream('opencode', advertised).thinking.options, [], 'effort levels must not turn into empty OpenCode variants');
console.log('upstream capability unknown/false states, advertised options and no automatic selection checks passed');

const variantsSource = await readFile(resolve(projectRoot, 'src/components/agent-profiles/variantValues.ts'), 'utf8');
const variantsCode = ts.transpileModule(variantsSource, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 } }).outputText;
const { parseVariantDrafts } = await import(`data:text/javascript;base64,${Buffer.from(variantsCode).toString('base64')}`);
const previousVariants = { high: { reasoningEffort: 'high', extra: { keep: true } } };
assert.deepEqual(parseVariantDrafts(['high'], {}, previousVariants), previousVariants);
assert.equal(parseVariantDrafts(['high'], { high: '{"reasoningEffort":"medium","extra":{"keep":true}}' }, previousVariants).high.reasoningEffort, 'medium');
for (const invalid of ['[]', 'null', '"high"', '{', '{"thinking":{"type":"enabled","budgetTokens":1023}}', '{"thinking":{"type":"enabled"}}']) {
assert.throws(() => parseVariantDrafts(['high'], { high: invalid }, previousVariants));
}
assert.deepEqual(parseVariantDrafts([], {}, previousVariants), {});
console.log('Variant parameters: same-name edits, unknown fields, deletion and invalid object/budget checks passed');
63 changes: 62 additions & 1 deletion src-tauri/src/agent_profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,18 @@ fn patch_for_opencode(
if !model.enabled {
continue;
}
if model.thinking.variant_values_changed
&& model
.thinking
.variant_values
.as_ref()
.is_some_and(|values| values.values().any(|value| !value.is_object()))
{
return Err(AgentProfileCommandError::validation(
"AGENT_PROFILE_VARIANT_OBJECT_REQUIRED",
"Each variant must contain a JSON object",
));
}
let variants = model.thinking.variant_values_changed.then(|| {
model
.thinking
Expand Down Expand Up @@ -4255,7 +4267,7 @@ mod tests {
}),
);
variant_values.insert("low".to_owned(), json!({ "reasoningEffort": "low" }));
let managed = ManagedProfileInput {
let mut managed = ManagedProfileInput {
providers: vec![ProviderProfileInput {
provider_id: "deepseek".to_owned(),
display_name: "DeepSeek".to_owned(),
Expand Down Expand Up @@ -4313,6 +4325,55 @@ mod tests {
})
);
assert_eq!(variants["low"], json!({ "reasoningEffort": "low" }));
managed.providers[0].models[0]
.thinking
.variant_values
.as_mut()
.unwrap()
.insert(
"high".into(),
json!({"reasoningEffort":"medium","extra":{"keep":true}}),
);
let edited = patch_for_opencode(&managed).unwrap();
assert_eq!(
edited.providers["deepseek"].models["deepseek-chat"]
.variants
.as_ref()
.unwrap()["high"]["reasoningEffort"],
"medium"
);
let root = std::env::temp_dir().join(format!("vibehub-variant-edit-{}", Uuid::new_v4()));
fs::create_dir_all(root.join(".config/opencode")).unwrap();
let path = root.join(".config/opencode/opencode.jsonc");
fs::write(
&path,
r#"{"provider":{"deepseek":{"models":{"deepseek-chat":{"custom":"keep"}}}}}"#,
)
.unwrap();
let target = RuntimeTarget::host(root.clone());
let before = v3::read_opencode_profile(&target, &path).unwrap();
v3::save_opencode_profile(&target, &path, Some(&before.revision), &edited).unwrap();
let after = v3::read_opencode_profile(&target, &path).unwrap();
assert_eq!(
after.providers[0].models[0]
.variant_values
.as_ref()
.unwrap()["high"],
json!({"reasoningEffort":"medium","extra":{"keep":true}})
);
assert!(fs::read_to_string(&path)
.unwrap()
.contains("\"custom\":\"keep\""));
fs::remove_dir_all(root).unwrap();
for invalid in [json!([]), json!(null), json!("high"), json!(3)] {
managed.providers[0].models[0]
.thinking
.variant_values
.as_mut()
.unwrap()
.insert("high".into(), invalid);
assert!(patch_for_opencode(&managed).is_err());
}
}

#[test]
Expand Down
24 changes: 18 additions & 6 deletions src/components/agent-profiles/AgentProfilesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { VariantParametersEditor } from './VariantParametersEditor';
import { parseVariantDrafts } from './variantValues';
import { ThinkingParametersEditor, type ThinkingParameters } from './ThinkingParametersEditor';
import { modelFromUpstream } from './upstreamModels';
import type { UpstreamModelMetadata } from '@/services/tauri';
Expand Down Expand Up @@ -114,6 +116,7 @@ type ModelForm = ThinkingParameters & {
options: string[];
custom_allowed: boolean;
variant_values: Record<string, unknown> | null;
variant_drafts: Record<string, string>;
};

function cloneProfile(profile: AgentProfileDocument): AgentProfileDocument {
Expand Down Expand Up @@ -350,6 +353,7 @@ function modelFormFrom(model: ModelProfile): ModelForm {
selected: model.thinking.selected || '',
options: [...model.thinking.options],
custom_allowed: model.thinking.custom_allowed,
variant_drafts: {},
variant_values: model.thinking.variant_values ? { ...model.thinking.variant_values } : null,
};
}
Expand All @@ -366,6 +370,7 @@ function emptyModelForm(_agent: AgentKind): ModelForm {
options: [],
custom_allowed: false,
variant_values: null,
variant_drafts: {},
};
}

Expand Down Expand Up @@ -896,11 +901,14 @@ export function AgentProfilesPanel() {
? provider.models.find((model) => model.model_id === modelEditor.modelId)
: undefined;
const previousOptions = previousModel?.thinking.options || [];
const variantValuesChanged = agent === 'opencode'
&& (previousModel === undefined || JSON.stringify(previousOptions) !== JSON.stringify(options));
const variantValues = modelForm.variant_values
? Object.fromEntries(options.map((option) => [option, modelForm.variant_values?.[option] ?? {}]))
: null;
let variantValues = modelForm.variant_values;
if (agent === 'opencode') {
try { variantValues = parseVariantDrafts(options, modelForm.variant_drafts, modelForm.variant_values); }
catch { setModelError(t('agentProfiles.variants.invalid')); return; }
}
const variantValuesChanged = agent === 'opencode' && (previousModel?.thinking.variant_values_changed === true
|| previousModel === undefined || JSON.stringify(previousOptions) !== JSON.stringify(options)
|| JSON.stringify(variantValues) !== JSON.stringify(previousModel.thinking.variant_values || {}));
const nextModel: ModelProfile = {
model_id: modelId,
display_name: displayName,
Expand Down Expand Up @@ -1467,7 +1475,7 @@ export function AgentProfilesPanel() {
</Dialog>

<Dialog open={modelEditor !== null} onOpenChange={(open) => !open && closeModelEditor()}>
<DialogContent className={cn('max-w-2xl', interactionGroupClass)}>
<DialogContent className={cn('max-h-[calc(100dvh-2rem)] max-w-2xl overflow-y-auto', interactionGroupClass)}>
<DialogHeader><DialogTitle>{t(modelEditor?.mode === 'create' ? 'agentProfiles.dialogs.model.createTitle' : 'agentProfiles.dialogs.model.editTitle')}</DialogTitle><DialogDescription>{t('agentProfiles.dialogs.model.description')}</DialogDescription></DialogHeader>
{modelForm && (
<div className="space-y-4">
Expand All @@ -1490,6 +1498,10 @@ export function AgentProfilesPanel() {
<div className="flex items-center gap-1"><Input value={variantInput} onChange={(event) => setVariantInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); const value = variantInput.trim(); if (value && !modelForm.options.includes(value)) setModelForm({ ...modelForm, options: [...modelForm.options, value], selected: modelForm.selected || value }); setVariantInput(''); } }} placeholder={t('agentProfiles.forms.addVariant')} className="h-8 w-32 text-xs" /><Button type="button" variant="outline" size="sm" onClick={() => { const value = variantInput.trim(); if (value && !modelForm.options.includes(value)) setModelForm({ ...modelForm, options: [...modelForm.options, value], selected: modelForm.selected || value }); setVariantInput(''); }}>{t('agentProfiles.common.add')}</Button></div>
</div>
</div>
{agent === 'opencode' && modelForm.options.map(name => <VariantParametersEditor key={name} name={name}
text={modelForm.variant_drafts[name] ?? JSON.stringify(modelForm.variant_values?.[name] ?? {}, null, 2)}
protocol={profileForEdit?.managed.providers.find(p => p.provider_id === modelEditor?.providerId)?.protocol.native_protocol || 'unknown'}
onChange={text => setModelForm({ ...modelForm, variant_drafts: { ...modelForm.variant_drafts, [name]: text } })} />)}
{agent === 'opencode' && <ThinkingParametersEditor value={modelForm} onChange={value => setModelForm({ ...modelForm, ...value })}
protocol={profileForEdit?.managed.providers.find(p => p.provider_id === modelEditor?.providerId)?.protocol.native_protocol || 'unknown'}
supportsEffort={modelForm.supports_effort} supportsReasoning={modelForm.supports_reasoning}
Expand Down
16 changes: 9 additions & 7 deletions src/components/agent-profiles/ThinkingParametersEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
Expand All @@ -15,27 +16,28 @@ export function ThinkingParametersEditor({ value, onChange, protocol, effortOpti
effortOptions?: string[]; thinkingTypes?: string[]; supportsEffort: boolean | null; supportsReasoning: boolean | null;
}) {
const { t } = useTranslation();
const id = useId();
const known = ['openai_chat_completions', 'openai_responses', 'anthropic_messages'].includes(protocol);
const modes = supportsReasoning === false ? [] : thinkingTypes.length ? thinkingTypes : ['enabled', 'adaptive'];
const availableModes = Array.from(new Set(['', ...modes, 'disabled', value.thinking_mode]));
return <fieldset className="space-y-3 rounded-lg border p-3" disabled={!known}>
<legend className="px-1 text-sm font-medium">{t('agentProfiles.thinkingParameters.title')}</legend>
<p className="text-xs text-muted-foreground">{t(`agentProfiles.thinkingParameters.${known ? 'hint' : 'protocolRequired'}`)}</p>
<div><Label htmlFor="thinking-effort">{t('agentProfiles.thinkingParameters.effort')}</Label>
<Input id="thinking-effort" value={value.reasoning_effort} list="thinking-effort-options" placeholder={t('agentProfiles.thinkingParameters.inherit')}
<div><Label htmlFor={id + 'thinking-effort'}>{t('agentProfiles.thinkingParameters.effort')}</Label>
<Input id={id + 'thinking-effort'} value={value.reasoning_effort} list={id + 'thinking-effort-options'} placeholder={t('agentProfiles.thinkingParameters.inherit')}
onChange={e => onChange({ ...value, reasoning_effort: e.target.value, effort_changed: true })} />
<datalist id="thinking-effort-options">{effortOptions.map(option => <option key={option} value={option} />)}</datalist>
<datalist id={id + 'thinking-effort-options'}>{effortOptions.map(option => <option key={option} value={option} />)}</datalist>
{supportsEffort === false && <p className="text-xs text-muted-foreground">{t('agentProfiles.thinkingParameters.unsupportedEffort')}</p>}
</div>
{protocol === 'anthropic_messages' && <>
<div><Label htmlFor="thinking-mode">{t('agentProfiles.thinkingParameters.mode')}</Label>
<select id="thinking-mode" className="mt-1 w-full rounded-md border bg-background p-2 text-sm" value={value.thinking_mode}
<div><Label htmlFor={id + 'thinking-mode'}>{t('agentProfiles.thinkingParameters.mode')}</Label>
<select id={id + 'thinking-mode'} className="mt-1 w-full rounded-md border bg-background p-2 text-sm" value={value.thinking_mode}
onChange={e => onChange({ ...value, thinking_mode: e.target.value, thinking_changed: true })}>
{availableModes.map(mode => <option key={mode} value={mode}>{t(`agentProfiles.thinkingParameters.${mode || 'inherit'}`, { defaultValue: mode })}</option>)}
</select>
</div>
{value.thinking_mode === 'enabled' && <div><Label htmlFor="thinking-budget">{t('agentProfiles.thinkingParameters.budget')}</Label>
<Input id="thinking-budget" type="number" min={1024} step={1} value={value.thinking_budget}
{value.thinking_mode === 'enabled' && <div><Label htmlFor={id + 'thinking-budget'}>{t('agentProfiles.thinkingParameters.budget')}</Label>
<Input id={id + 'thinking-budget'} type="number" min={1024} step={1} value={value.thinking_budget}
onChange={e => onChange({ ...value, thinking_budget: e.target.value, thinking_changed: true })} />
</div>}
</>}
Expand Down
42 changes: 42 additions & 0 deletions src/components/agent-profiles/VariantParametersEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { ThinkingParametersEditor } from './ThinkingParametersEditor';
import { parseVariantObject } from './variantValues';

export function VariantParametersEditor({ name, text, protocol, onChange }: { name: string; text: string; protocol: string; onChange: (text: string) => void }) {
const { t } = useTranslation();
let object: Record<string, unknown> | null = null;
try { object = parseVariantObject(text); } catch { /* Keep invalid raw text editable. */ }
const thinking = object?.thinking && typeof object.thinking === 'object' ? object.thinking as Record<string, unknown> : {};
const effortKey = protocol === 'anthropic_messages' ? 'effort' : 'reasoningEffort';
const update = (value: Record<string, unknown>) => onChange(JSON.stringify(value, null, 2));
return <section className="space-y-3 rounded-lg border p-3">
<h4 className="text-sm font-medium">{name}</h4>
{object && <>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={object.disabled === true} onChange={e => update({ ...object, disabled: e.target.checked })} />{t('agentProfiles.variants.disabled')}</label>
<ThinkingParametersEditor protocol={protocol} supportsEffort={null} supportsReasoning={null}
value={{ reasoning_effort: String(object[effortKey] ?? ''), thinking_mode: String(thinking.type ?? ''), thinking_budget: String(thinking.budgetTokens ?? ''), effort_changed: false, thinking_changed: false }}
onChange={value => {
const next = { ...object };
if (value.effort_changed) { if (value.reasoning_effort.trim()) next[effortKey] = value.reasoning_effort.trim(); else delete next[effortKey]; }
if (value.thinking_changed) {
if (!value.thinking_mode) delete next.thinking;
else {
const nextThinking: Record<string, unknown> = { ...thinking, type: value.thinking_mode };
if (value.thinking_mode === 'enabled' && value.thinking_budget) nextThinking.budgetTokens = Number(value.thinking_budget);
else delete nextThinking.budgetTokens;
next.thinking = nextThinking;
}
}
update(next);
}} />
<label className="block text-sm">{t('agentProfiles.variants.temperature')}<Input type="number" step="any" value={typeof object.temperature === 'number' ? object.temperature : ''} onChange={e => {
const next = { ...object }; if (e.target.value === '') delete next.temperature; else next.temperature = Number(e.target.value); update(next);
}} /></label>
</>}
<details open={!object}><summary className="cursor-pointer text-xs text-muted-foreground">{t('agentProfiles.variants.allParameters')}</summary>
<textarea aria-label={t('agentProfiles.variants.jsonLabel', { name })} className="mt-2 min-h-28 w-full rounded-md border bg-background p-2 font-mono text-xs" value={text} onChange={e => onChange(e.target.value)} />
</details>
{!object && <p role="alert" className="text-xs text-destructive">{t('agentProfiles.variants.invalid')}</p>}
</section>;
}
19 changes: 19 additions & 0 deletions src/components/agent-profiles/variantValues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function parseVariantObject(text: string): Record<string, unknown> {
const value: unknown = JSON.parse(text);
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('variantObjectRequired');
const object = value as Record<string, unknown>;
return object;
}
export function parseVariantDrafts(names: string[], drafts: Record<string, string>, previous: Record<string, unknown> | null): Record<string, unknown> {
return Object.fromEntries(names.map(name => [name, validateVariantObject(drafts[name] ?? JSON.stringify(previous?.[name] ?? {}))]));
}

function validateVariantObject(text: string): Record<string, unknown> {
const object = parseVariantObject(text);
const thinking = object.thinking;
if (thinking && typeof thinking === 'object' && !Array.isArray(thinking)) {
const budget = (thinking as Record<string, unknown>).budgetTokens;
if (((thinking as Record<string, unknown>).type === 'enabled' || budget !== undefined) && (!Number.isSafeInteger(budget) || Number(budget) < 1024)) throw new Error('invalidBudget');
}
return object;
}
7 changes: 7 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,13 @@
"disabled": "Disabled",
"invalidBudget": "Enter a whole-number thinking budget of at least 1024 tokens.",
"unsupportedEffort": "This model declares effort unsupported. Clear the value to inherit the default."
},
"variants": {
"disabled": "Disable this variant",
"temperature": "Temperature (empty inherits)",
"allParameters": "All parameters (JSON object)",
"jsonLabel": "Parameters for {{name}}",
"invalid": "Each variant must be a valid JSON object. Any thinking budget must be an integer of at least 1024."
}
},
"update": {
Expand Down
7 changes: 7 additions & 0 deletions src/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,13 @@
"disabled": "關閉",
"invalidBudget": "思考預算必須是至少 1024 的整數。",
"unsupportedEffort": "此模型宣告不支援思考強度。請清空該值以繼承預設設定。"
},
"variants": {
"disabled": "停用此變體",
"temperature": "溫度(留空繼承)",
"allParameters": "全部參數(JSON 物件)",
"jsonLabel": "{{name}} 的參數",
"invalid": "每個變體必須是有效的 JSON 物件。思考預算必須是至少 1024 的整數。"
}
},
"update": {
Expand Down
Loading
Loading