mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Fix provider model testing and add MCP coming soon placeholder (#2570)
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
saveAISettings,
|
||||
setCloudProviderKey,
|
||||
setOpenAICompatEndpointKey,
|
||||
testProviderModel,
|
||||
} from '../../../services/api/aiSettingsApi';
|
||||
import {
|
||||
creditsApi,
|
||||
@@ -209,6 +210,14 @@ function maskKeyLabel(hasKey: boolean): string {
|
||||
return hasKey ? '•••• configured' : 'Not configured';
|
||||
}
|
||||
|
||||
function slugifyCustomProviderName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Default auth style for a slug. Built-in slugs map to their known styles;
|
||||
* everything else (custom + third-party slugs the user types in) defaults
|
||||
@@ -1671,6 +1680,12 @@ function humanizeModelId(id: string): string {
|
||||
return id.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function appendTemperatureToProviderString(provider: string, temperature: number | null): string {
|
||||
if (temperature == null || !Number.isFinite(temperature)) return provider;
|
||||
const rounded = Math.round(temperature * 100) / 100;
|
||||
return `${provider}@${String(rounded)}`;
|
||||
}
|
||||
|
||||
const CustomRoutingDialog = ({
|
||||
workload,
|
||||
initial,
|
||||
@@ -1710,6 +1725,11 @@ const CustomRoutingDialog = ({
|
||||
const [cloudModelsLoading, setCloudModelsLoading] = useState(false);
|
||||
const [cloudModelsError, setCloudModelsError] = useState<string | null>(null);
|
||||
const [modelsKey, setModelsKey] = useState(0);
|
||||
const [testBusy, setTestBusy] = useState(false);
|
||||
const [testReply, setTestReply] = useState<string | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
const [testStartedAt, setTestStartedAt] = useState<string | null>(null);
|
||||
const testRequestIdRef = useRef(0);
|
||||
// Optional temperature override for this workload. `null` = use provider/global default;
|
||||
// a finite number means "send `temperature: X` upstream for this workload only".
|
||||
const [temperature, setTemperature] = useState<number | null>(
|
||||
@@ -1762,6 +1782,28 @@ const CustomRoutingDialog = ({
|
||||
}, [selectedSlug, modelsKey]);
|
||||
|
||||
const canSave = source !== null && model.trim().length > 0;
|
||||
const canTest = canSave && !cloudModelsLoading;
|
||||
|
||||
const resetTestState = () => {
|
||||
testRequestIdRef.current += 1;
|
||||
setTestReply(null);
|
||||
setTestError(null);
|
||||
setTestStartedAt(null);
|
||||
setTestBusy(false);
|
||||
};
|
||||
|
||||
const currentProviderString =
|
||||
source == null
|
||||
? null
|
||||
: source.kind === 'cloud'
|
||||
? appendTemperatureToProviderString(
|
||||
`${source.providerSlug}:${model.trim()}`,
|
||||
temperature == null || !Number.isFinite(temperature) ? null : temperature
|
||||
)
|
||||
: appendTemperatureToProviderString(
|
||||
`ollama:${model.trim()}`,
|
||||
temperature == null || !Number.isFinite(temperature) ? null : temperature
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!source || !canSave) return;
|
||||
@@ -1778,6 +1820,28 @@ const CustomRoutingDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!currentProviderString || !canTest) return;
|
||||
const requestId = testRequestIdRef.current + 1;
|
||||
testRequestIdRef.current = requestId;
|
||||
setTestBusy(true);
|
||||
setTestReply(null);
|
||||
setTestError(null);
|
||||
setTestStartedAt(new Date().toLocaleTimeString());
|
||||
try {
|
||||
const result = await testProviderModel(workload.id, currentProviderString, 'Hello world');
|
||||
if (testRequestIdRef.current !== requestId) return;
|
||||
setTestReply(result.reply);
|
||||
} catch (err) {
|
||||
if (testRequestIdRef.current !== requestId) return;
|
||||
setTestError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (testRequestIdRef.current === requestId) {
|
||||
setTestBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const noProviders = customCloud.length === 0 && !localAvailable;
|
||||
|
||||
return (
|
||||
@@ -1830,6 +1894,7 @@ const CustomRoutingDialog = ({
|
||||
const colonIdx = e.target.value.indexOf(':');
|
||||
const kind = e.target.value.slice(0, colonIdx);
|
||||
const slug = e.target.value.slice(colonIdx + 1);
|
||||
resetTestState();
|
||||
if (kind === 'local') {
|
||||
setSource({ kind: 'local' });
|
||||
setModel(localModels[0]?.id ?? '');
|
||||
@@ -1855,7 +1920,10 @@ const CustomRoutingDialog = ({
|
||||
{source?.kind === 'local' ? (
|
||||
<select
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setModel(e.target.value);
|
||||
}}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{localModels.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
@@ -1888,7 +1956,10 @@ const CustomRoutingDialog = ({
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setModel(e.target.value);
|
||||
}}
|
||||
placeholder={selectedCloud ? `${selectedCloud.slug} model id` : 'model-id'}
|
||||
className="w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
@@ -1896,7 +1967,10 @@ const CustomRoutingDialog = ({
|
||||
) : cloudModels.length > 0 ? (
|
||||
<select
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setModel(e.target.value);
|
||||
}}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{!model && <option value="">Select a model…</option>}
|
||||
{/* Keep existing value selectable even if the provider no longer lists it */}
|
||||
@@ -1913,7 +1987,10 @@ const CustomRoutingDialog = ({
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setModel(e.target.value);
|
||||
}}
|
||||
placeholder={selectedCloud ? `${selectedCloud.slug} model id` : 'model-id'}
|
||||
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
@@ -1928,7 +2005,10 @@ const CustomRoutingDialog = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={temperature != null}
|
||||
onChange={e => setTemperature(e.target.checked ? 0.7 : null)}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setTemperature(e.target.checked ? 0.7 : null);
|
||||
}}
|
||||
className="h-3.5 w-3.5 rounded border-stone-300 dark:border-neutral-700 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Temperature override
|
||||
@@ -1948,7 +2028,10 @@ const CustomRoutingDialog = ({
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={temperature}
|
||||
onChange={e => setTemperature(Number(e.target.value))}
|
||||
onChange={e => {
|
||||
resetTestState();
|
||||
setTemperature(Number(e.target.value));
|
||||
}}
|
||||
className="flex-1 accent-primary-500"
|
||||
/>
|
||||
<input
|
||||
@@ -1960,7 +2043,10 @@ const CustomRoutingDialog = ({
|
||||
value={temperature}
|
||||
onChange={e => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) setTemperature(Math.max(0, Math.min(2, v)));
|
||||
if (Number.isFinite(v)) {
|
||||
resetTestState();
|
||||
setTemperature(Math.max(0, Math.min(2, v)));
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
@@ -1970,6 +2056,51 @@ const CustomRoutingDialog = ({
|
||||
Lower = more deterministic. Leave unchecked to use the provider default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(testBusy || testReply || testError || testStartedAt) && (
|
||||
<div
|
||||
role={testError ? 'alert' : 'status'}
|
||||
className={`rounded-lg border px-3 py-2 text-xs ${
|
||||
testError
|
||||
? 'border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 text-coral-700 dark:text-coral-300'
|
||||
: testBusy
|
||||
? 'border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-800 dark:text-amber-200'
|
||||
: 'border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 text-sage-800 dark:text-sage-200'
|
||||
}`}>
|
||||
<div className="font-semibold">
|
||||
{testError ? 'Test failed' : testBusy ? 'Testing model…' : 'Model response'}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="font-mono text-[11px] text-current/80">
|
||||
Provider: {currentProviderString ?? '—'}
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-current/80">Prompt: Hello world</div>
|
||||
{testStartedAt && (
|
||||
<div className="font-mono text-[11px] text-current/80">
|
||||
Started: {testStartedAt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{testBusy ? (
|
||||
<div className="mt-2 rounded-md border border-current/15 bg-white/50 px-3 py-2 text-[12px] dark:bg-black/10">
|
||||
Waiting for response from the selected model…
|
||||
</div>
|
||||
) : testError ? (
|
||||
<div className="mt-2 rounded-md border border-current/15 bg-white/50 px-3 py-2 font-mono text-[11px] whitespace-pre-wrap break-words dark:bg-black/10">
|
||||
{testError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-current/80">
|
||||
Response
|
||||
</div>
|
||||
<div className="rounded-md border border-current/15 bg-white/70 px-3 py-3 text-[13px] leading-relaxed text-stone-900 whitespace-pre-wrap break-words dark:bg-black/10 dark:text-neutral-100">
|
||||
{testReply}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1980,6 +2111,13 @@ const CustomRoutingDialog = ({
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTest()}
|
||||
disabled={!canTest || testBusy}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{testBusy ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
@@ -2141,6 +2279,13 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
});
|
||||
} else if (isLocalRuntime && slug === 'lmstudio') {
|
||||
await openhumanUpdateLocalAiSettings({
|
||||
base_url: endpoint,
|
||||
provider: 'lm_studio',
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (slug !== 'openhuman') {
|
||||
@@ -2728,21 +2873,33 @@ const CloudProviderEditor = ({
|
||||
onClearKey: (slug: string) => Promise<void> | void;
|
||||
}) => {
|
||||
const { t } = useT();
|
||||
const defaultSlug: string =
|
||||
initial?.slug ??
|
||||
(['openai', 'anthropic', 'openrouter', 'orcarouter', 'custom'] as const).find(
|
||||
s => !existingSlugs.includes(s)
|
||||
) ??
|
||||
'custom';
|
||||
const [slug, setSlug] = useState<string>(defaultSlug);
|
||||
const [label, setLabel] = useState<string>(
|
||||
initial?.label ?? BUILTIN_PROVIDER_META[defaultSlug]?.label ?? defaultSlug
|
||||
);
|
||||
const [endpoint, setEndpoint] = useState(initial?.endpoint ?? defaultEndpointFor(defaultSlug));
|
||||
const [label, setLabel] = useState<string>(initial?.label ?? '');
|
||||
const [endpoint, setEndpoint] = useState(initial?.endpoint ?? '');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const isOpenHuman = slug === 'openhuman';
|
||||
const slug = initial?.slug ?? slugifyCustomProviderName(label);
|
||||
const hasReservedSlugCollision =
|
||||
!initial &&
|
||||
[
|
||||
'cloud',
|
||||
'openhuman',
|
||||
'pid',
|
||||
'openai',
|
||||
'anthropic',
|
||||
'openrouter',
|
||||
'orcarouter',
|
||||
'custom',
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
].includes(slug);
|
||||
const slugError = !slug
|
||||
? 'Enter a provider name to generate a slug.'
|
||||
: existingSlugs.includes(slug)
|
||||
? 'That provider name is already in use.'
|
||||
: hasReservedSlugCollision
|
||||
? 'Choose a different provider name.'
|
||||
: null;
|
||||
const hasExistingKey = (initial?.maskedKey ?? '').startsWith('••••');
|
||||
|
||||
return (
|
||||
@@ -2761,74 +2918,60 @@ const CloudProviderEditor = ({
|
||||
</div>
|
||||
<div className="space-y-3 px-4 py-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
Provider slug
|
||||
</label>
|
||||
<select
|
||||
value={slug}
|
||||
onChange={e => {
|
||||
const next = e.target.value;
|
||||
setSlug(next);
|
||||
setLabel(BUILTIN_PROVIDER_META[next]?.label ?? next);
|
||||
if (!initial) {
|
||||
setEndpoint(defaultEndpointFor(next));
|
||||
}
|
||||
}}
|
||||
disabled={!!initial}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200">
|
||||
{(['openai', 'anthropic', 'openrouter', 'orcarouter', 'custom'] as const)
|
||||
.filter(s => s === slug || !existingSlugs.includes(s))
|
||||
.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{BUILTIN_PROVIDER_META[s]?.label ?? s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
Display label
|
||||
<label
|
||||
htmlFor="cloud-provider-name"
|
||||
className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="cloud-provider-name"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
|
||||
placeholder="My Provider"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
Slug:{' '}
|
||||
<span className="font-mono text-stone-700 dark:text-neutral-200">{slug || '—'}</span>
|
||||
</div>
|
||||
{slugError ? (
|
||||
<div className="mt-1 text-[11px] text-coral-600 dark:text-coral-300">{slugError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
Endpoint
|
||||
<label
|
||||
htmlFor="cloud-provider-openai-url"
|
||||
className="text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
OpenAI URL
|
||||
</label>
|
||||
<input
|
||||
id="cloud-provider-openai-url"
|
||||
value={endpoint}
|
||||
onChange={e => setEndpoint(e.target.value)}
|
||||
disabled={isOpenHuman}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
|
||||
placeholder="https://api.example.com/v1"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
<span>API key</span>
|
||||
{hasExistingKey && (
|
||||
<button
|
||||
onClick={() => void onClearKey(slug)}
|
||||
className="text-[10px] font-medium normal-case text-coral-600 dark:text-coral-300 hover:text-coral-700 dark:text-coral-300">
|
||||
{t('settings.ai.clearStoredKey')}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
aria-label="API key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
|
||||
placeholder={hasExistingKey ? 'Leave blank to keep existing key' : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
{!isOpenHuman && (
|
||||
<div>
|
||||
<label className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
<span>API key</span>
|
||||
{hasExistingKey && (
|
||||
<button
|
||||
onClick={() => void onClearKey(slug)}
|
||||
className="text-[10px] font-medium normal-case text-coral-600 dark:text-coral-300 hover:text-coral-700 dark:text-coral-300">
|
||||
{t('settings.ai.clearStoredKey')}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 font-mono text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-neutral-500 dark:placeholder:text-neutral-500 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200"
|
||||
placeholder={hasExistingKey ? 'Leave blank to keep existing key' : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{submitError ? <ProviderSetupErrorNotice error={submitError} /> : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-stone-200 dark:border-neutral-800 px-4 py-3">
|
||||
@@ -2843,13 +2986,16 @@ const CloudProviderEditor = ({
|
||||
setSaving(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
if (slugError) {
|
||||
throw new Error(slugError);
|
||||
}
|
||||
await onSubmit(
|
||||
{
|
||||
id: initial?.id ?? '',
|
||||
slug,
|
||||
label: label.trim() || slug,
|
||||
endpoint: endpoint.trim(),
|
||||
authStyle: initial?.authStyle ?? authStyleForSlug(slug),
|
||||
authStyle: initial?.authStyle ?? 'bearer',
|
||||
maskedKey: maskKeyLabel(hasExistingKey || apiKey.length > 0),
|
||||
},
|
||||
apiKey.trim()
|
||||
@@ -2868,7 +3014,7 @@ const CloudProviderEditor = ({
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={saving || !endpoint.trim()}
|
||||
disabled={saving || !endpoint.trim() || Boolean(slugError)}
|
||||
className="rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-600 disabled:opacity-50">
|
||||
{saving
|
||||
? t('settings.ai.saving')
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
saveAISettings,
|
||||
setCloudProviderKey,
|
||||
setOpenAICompatEndpointKey,
|
||||
testProviderModel,
|
||||
} from '../../../../services/api/aiSettingsApi';
|
||||
import { creditsApi } from '../../../../services/api/creditsApi';
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
@@ -41,6 +42,7 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
saveAISettings: vi.fn(),
|
||||
loadLocalProviderSnapshot: vi.fn(),
|
||||
setOpenAICompatEndpointKey: vi.fn(),
|
||||
testProviderModel: vi.fn(),
|
||||
clearOpenAICompatEndpointKey: vi.fn().mockResolvedValue(undefined),
|
||||
setCloudProviderKey: vi.fn(),
|
||||
clearCloudProviderKey: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -206,6 +208,7 @@ describe('AIPanel', () => {
|
||||
vi.mocked(setOpenAICompatEndpointKey).mockResolvedValue(undefined);
|
||||
vi.mocked(clearOpenAICompatEndpointKey).mockResolvedValue(undefined);
|
||||
vi.mocked(setCloudProviderKey).mockResolvedValue(undefined);
|
||||
vi.mocked(testProviderModel).mockResolvedValue({ reply: 'Hello from the selected model.' });
|
||||
vi.mocked(listProviderModels).mockResolvedValue([]);
|
||||
vi.mocked(connectOpenRouterViaOAuth).mockResolvedValue('sk-or-oauth');
|
||||
vi.mocked(openhumanHeartbeatSettingsGet).mockResolvedValue({
|
||||
@@ -474,6 +477,8 @@ describe('AIPanel', () => {
|
||||
|
||||
// The full CloudProviderEditor should appear (has "Add cloud provider" heading).
|
||||
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
|
||||
expect(screen.getByLabelText(/^Name$/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/OpenAI URL/i)).toBeInTheDocument();
|
||||
// The simple ProviderKeyDialog should NOT appear.
|
||||
expect(screen.queryByRole('dialog', { name: /Connect Custom/i })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -629,18 +634,52 @@ describe('AIPanel', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Connect Custom/i }));
|
||||
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
|
||||
fireEvent.change(screen.getByLabelText(/^Name$/i), { target: { value: 'Team Gateway' } });
|
||||
fireEvent.change(screen.getByLabelText(/OpenAI URL/i), {
|
||||
target: { value: 'https://api.openai.com/v1' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('sk-...'), { target: { value: 'sk-test-key' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add provider/i }));
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(
|
||||
within(alert).getByText(
|
||||
'Could not reach OpenAI: Provider teapot says no. Try another endpoint.'
|
||||
'Could not reach Team Gateway: Provider teapot says no. Try another endpoint.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(within(alert).getByText('Technical details')).toBeInTheDocument();
|
||||
expect(within(alert).getByText(/provider returned 418/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('switch', { name: /Disconnect OpenAI/i })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: /Disconnect Team Gateway/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('derives the custom provider slug from the entered name', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect Custom/i })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Connect Custom/i }));
|
||||
await waitFor(() => expect(screen.getByText(/Add cloud provider/i)).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^Name$/i), { target: { value: 'My Team Gateway' } });
|
||||
expect(screen.getByText(/Slug:/i)).toHaveTextContent('Slug: my-team-gateway');
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/OpenAI URL/i), {
|
||||
target: { value: 'https://gateway.example.com/v1' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('sk-...'), { target: { value: 'sk-team-key' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add provider/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(setCloudProviderKey)).toHaveBeenCalledWith('my-team-gateway', 'sk-team-key')
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(listProviderModels)).toHaveBeenCalledWith('my-team-gateway')
|
||||
);
|
||||
});
|
||||
|
||||
// ─── local runtime: Ollama endpoint URL dialog ──────────────────────────────
|
||||
@@ -707,6 +746,30 @@ describe('AIPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('LM Studio save persists the local_ai provider and endpoint', async () => {
|
||||
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
|
||||
renderWithProviders(<AIPanel />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('switch', { name: /Connect LM Studio/i })).toBeInTheDocument()
|
||||
);
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Connect LM Studio/i }));
|
||||
const dialog = await screen.findByRole('dialog', { name: /Connect LM Studio/i });
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText(/Endpoint URL/i), {
|
||||
target: { value: 'http://127.0.0.1:1234/v1' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => expect(openhumanUpdateLocalAiSettingsMock).toHaveBeenCalled());
|
||||
const [arg] = vi.mocked(openhumanUpdateLocalAiSettingsMock).mock.calls[0];
|
||||
expect(arg).toMatchObject({
|
||||
base_url: 'http://127.0.0.1:1234/v1',
|
||||
provider: 'lm_studio',
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Custom routing dialog: per-workload temperature override ───────────────
|
||||
|
||||
it('Custom routing dialog saves the routing change immediately from the modal', async () => {
|
||||
@@ -767,6 +830,125 @@ describe('AIPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Custom routing dialog can test the selected cloud model and show its reply', async () => {
|
||||
const settingsWithOpenAI = {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_openai_1',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
...baseSettings.routing,
|
||||
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
|
||||
},
|
||||
};
|
||||
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }]);
|
||||
vi.mocked(testProviderModel).mockResolvedValue({ reply: 'Hello from gpt-4o.' });
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
const reasoningRow = await screen.findByText(/Main chat agent/i);
|
||||
const rowEl = reasoningRow.closest('div.flex.items-center.justify-between');
|
||||
expect(rowEl).not.toBeNull();
|
||||
fireEvent.click(within(rowEl as HTMLElement).getByRole('button', { name: /Custom/i }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Custom routing/i });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Test$/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(testProviderModel)).toHaveBeenCalledWith(
|
||||
'reasoning',
|
||||
'openai:gpt-4o',
|
||||
'Hello world'
|
||||
)
|
||||
);
|
||||
expect(await within(dialog).findByText('Model response')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Hello from gpt-4o.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Custom routing dialog shows in-flight test status immediately', async () => {
|
||||
const settingsWithOpenAI = {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_openai_1',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
...baseSettings.routing,
|
||||
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
|
||||
},
|
||||
};
|
||||
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
|
||||
let resolveTest: (value: { reply: string }) => void = () => {};
|
||||
const pendingTest = new Promise<{ reply: string }>(resolve => {
|
||||
resolveTest = resolve;
|
||||
});
|
||||
vi.mocked(testProviderModel).mockReturnValue(pendingTest);
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
const reasoningRow = await screen.findByText(/Main chat agent/i);
|
||||
const rowEl = reasoningRow.closest('div.flex.items-center.justify-between');
|
||||
expect(rowEl).not.toBeNull();
|
||||
fireEvent.click(within(rowEl as HTMLElement).getByRole('button', { name: /Custom/i }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Custom routing/i });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Test$/i }));
|
||||
|
||||
expect(await within(dialog).findByText('Testing model…')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/Provider: openai:gpt-4o/i)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/Prompt: Hello world/i)).toBeInTheDocument();
|
||||
|
||||
resolveTest({ reply: 'Hello from gpt-4o.' });
|
||||
expect(await within(dialog).findByText('Model response')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Custom routing dialog shows test errors inline', async () => {
|
||||
const settingsWithOpenAI = {
|
||||
cloudProviders: [
|
||||
{
|
||||
id: 'p_openai_1',
|
||||
slug: 'openai',
|
||||
label: 'OpenAI',
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
auth_style: 'bearer' as const,
|
||||
has_api_key: true,
|
||||
},
|
||||
],
|
||||
routing: {
|
||||
...baseSettings.routing,
|
||||
reasoning: { kind: 'cloud' as const, providerSlug: 'openai', model: 'gpt-4o' },
|
||||
},
|
||||
};
|
||||
vi.mocked(loadAISettings).mockResolvedValue(settingsWithOpenAI);
|
||||
vi.mocked(listProviderModels).mockResolvedValue([{ id: 'gpt-4o' }]);
|
||||
vi.mocked(testProviderModel).mockRejectedValue(new Error('401 invalid api key'));
|
||||
|
||||
renderWithProviders(<AIPanel />);
|
||||
|
||||
const reasoningRow = await screen.findByText(/Main chat agent/i);
|
||||
const rowEl = reasoningRow.closest('div.flex.items-center.justify-between');
|
||||
expect(rowEl).not.toBeNull();
|
||||
fireEvent.click(within(rowEl as HTMLElement).getByRole('button', { name: /Custom/i }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /Custom routing/i });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Test$/i }));
|
||||
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('401 invalid api key');
|
||||
});
|
||||
|
||||
it('renders background loop diagnostics with newest spend row and budget math', async () => {
|
||||
// BackgroundLoopControls was moved out of AIPanel into standalone panels.
|
||||
renderWithProviders(
|
||||
|
||||
@@ -50,6 +50,7 @@ const ar1: TranslationMap = {
|
||||
'common.showLess': 'عرض أقل',
|
||||
'common.submit': 'إرسال',
|
||||
'common.continue': 'متابعة',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'عام',
|
||||
'settings.featuresAndAI': 'الميزات والذكاء الاصطناعي',
|
||||
'settings.billingAndRewards': 'الفوترة والمكافآت',
|
||||
@@ -428,6 +429,9 @@ const ar1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const bn1: TranslationMap = {
|
||||
'common.showLess': 'কম দেখুন',
|
||||
'common.submit': 'জমা দিন',
|
||||
'common.continue': 'চালিয়ে যান',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'সাধারণ',
|
||||
'settings.featuresAndAI': 'ফিচার ও AI',
|
||||
'settings.billingAndRewards': 'বিলিং ও পুরস্কার',
|
||||
@@ -437,6 +438,9 @@ const bn1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const de1: TranslationMap = {
|
||||
'common.showLess': 'Weniger anzeigen',
|
||||
'common.submit': 'Senden',
|
||||
'common.continue': 'Weiter',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'Allgemein',
|
||||
'settings.featuresAndAI': 'Funktionen und KI',
|
||||
'settings.billingAndRewards': 'Abrechnung und Prämien',
|
||||
@@ -449,6 +450,9 @@ const de1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const en1: TranslationMap = {
|
||||
'common.showLess': 'Show less',
|
||||
'common.submit': 'Submit',
|
||||
'common.continue': 'Continue',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'General',
|
||||
'settings.featuresAndAI': 'Features & AI',
|
||||
'settings.billingAndRewards': 'Billing & Rewards',
|
||||
@@ -210,6 +211,9 @@ const en1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'memory.title': 'Memory',
|
||||
'memory.search': 'Search memories...',
|
||||
'memory.noResults': 'No memories found',
|
||||
|
||||
@@ -50,6 +50,7 @@ const es1: TranslationMap = {
|
||||
'common.showLess': 'Ver menos',
|
||||
'common.submit': 'Enviar',
|
||||
'common.continue': 'Continuar',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'General',
|
||||
'settings.featuresAndAI': 'Funciones e IA',
|
||||
'settings.billingAndRewards': 'Facturación y recompensas',
|
||||
@@ -449,6 +450,9 @@ const es1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const fr1: TranslationMap = {
|
||||
'common.showLess': 'Afficher moins',
|
||||
'common.submit': 'Envoyer',
|
||||
'common.continue': 'Continuer',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'Général',
|
||||
'settings.featuresAndAI': 'Fonctionnalités & IA',
|
||||
'settings.billingAndRewards': 'Facturation & Récompenses',
|
||||
@@ -451,6 +452,9 @@ const fr1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const hi1: TranslationMap = {
|
||||
'common.showLess': 'कम दिखाएं',
|
||||
'common.submit': 'सबमिट करें',
|
||||
'common.continue': 'जारी रखें',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'सामान्य',
|
||||
'settings.featuresAndAI': 'फीचर्स और AI',
|
||||
'settings.billingAndRewards': 'बिलिंग और रिवॉर्ड',
|
||||
@@ -434,6 +435,9 @@ const hi1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const id1: TranslationMap = {
|
||||
'common.showLess': 'Tampilkan sedikit',
|
||||
'common.submit': 'Kirim',
|
||||
'common.continue': 'Lanjutkan',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'Umum',
|
||||
'settings.featuresAndAI': 'Fitur & AI',
|
||||
'settings.billingAndRewards': 'Tagihan & Hadiah',
|
||||
@@ -440,6 +441,9 @@ const id1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const it1: TranslationMap = {
|
||||
'common.showLess': 'Mostra meno',
|
||||
'common.submit': 'Invia',
|
||||
'common.continue': 'Continua',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'Generale',
|
||||
'settings.featuresAndAI': 'Funzionalità e AI',
|
||||
'settings.billingAndRewards': 'Fatturazione e premi',
|
||||
@@ -444,6 +445,9 @@ const it1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const ko1: TranslationMap = {
|
||||
'common.showLess': '간단히 보기',
|
||||
'common.submit': '제출',
|
||||
'common.continue': '계속',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': '일반',
|
||||
'settings.featuresAndAI': '기능 및 AI',
|
||||
'settings.billingAndRewards': '결제 및 보상',
|
||||
@@ -425,6 +426,9 @@ const ko1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const pt1: TranslationMap = {
|
||||
'common.showLess': 'Mostrar menos',
|
||||
'common.submit': 'Enviar',
|
||||
'common.continue': 'Continuar',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'Geral',
|
||||
'settings.featuresAndAI': 'Recursos e IA',
|
||||
'settings.billingAndRewards': 'Cobrança e Recompensas',
|
||||
@@ -449,6 +450,9 @@ const pt1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const ru1: TranslationMap = {
|
||||
'common.showLess': 'Показать меньше',
|
||||
'common.submit': 'Отправить',
|
||||
'common.continue': 'Продолжить',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': 'Общие',
|
||||
'settings.featuresAndAI': 'Функции и AI',
|
||||
'settings.billingAndRewards': 'Оплата и награды',
|
||||
@@ -439,6 +440,9 @@ const ru1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -50,6 +50,7 @@ const zhCN1: TranslationMap = {
|
||||
'common.showLess': '收起',
|
||||
'common.submit': '提交',
|
||||
'common.continue': '继续',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
'settings.general': '通用',
|
||||
'settings.featuresAndAI': '功能与 AI',
|
||||
'settings.billingAndRewards': '账单与奖励',
|
||||
@@ -421,6 +422,9 @@ const zhCN1: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Channels',
|
||||
'skills.tabs.mcp': 'MCP Servers',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.about.connection': 'Connection',
|
||||
'settings.about.connectionMode': 'Mode',
|
||||
'settings.about.connectionModeLocal': 'Local',
|
||||
|
||||
@@ -52,6 +52,7 @@ const en: TranslationMap = {
|
||||
'common.showLess': 'Show less',
|
||||
'common.submit': 'Submit',
|
||||
'common.continue': 'Continue',
|
||||
'common.comingSoon': 'Coming Soon',
|
||||
|
||||
// Settings Home
|
||||
'settings.general': 'General',
|
||||
@@ -1817,6 +1818,9 @@ const en: TranslationMap = {
|
||||
'settings.ai.openAiCompat.setKey': 'Set key',
|
||||
'settings.ai.openAiCompat.title': 'OpenAI-compatible endpoint',
|
||||
'settings.ai.providerLabel': 'Provider',
|
||||
'skills.mcpComingSoon.title': 'MCP Servers',
|
||||
'skills.mcpComingSoon.description':
|
||||
'MCP server management is coming soon. This tab will be the home for discovering, connecting, and monitoring your MCP server integrations.',
|
||||
'settings.ai.routing': 'Routing',
|
||||
'settings.ai.routingCustom': 'Custom routing',
|
||||
'settings.ai.routingDefault': 'Default',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
|
||||
import McpServersTab from '../components/channels/mcp/McpServersTab';
|
||||
import ComposioConnectModal from '../components/composio/ComposioConnectModal';
|
||||
import {
|
||||
composioToolkitMeta,
|
||||
@@ -284,6 +283,37 @@ function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function McpComingSoonPanel() {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-stone-300 dark:border-neutral-700 bg-stone-50/80 dark:bg-neutral-900/80 px-6 py-16 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-500/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 7.5h10.5m-10.5 4.5h10.5m-10.5 4.5h6m-9.75 3h13.5A2.25 2.25 0 0 0 19.5 17.25V6.75A2.25 2.25 0 0 0 17.25 4.5H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5A2.25 2.25 0 0 0 6.75 19.5Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('skills.mcpComingSoon.title')}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.mcpComingSoon.description')}
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
{t('common.comingSoon')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Built-in skill definitions ────────────────────────────────────────────────
|
||||
|
||||
const BUILT_IN_SKILLS: Array<{
|
||||
@@ -1035,7 +1065,7 @@ export default function Skills() {
|
||||
{t('channels.mcp.description')}
|
||||
</p>
|
||||
</div>
|
||||
<McpServersTab />
|
||||
<McpComingSoonPanel />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import '../../test/mockDefaultSkillStatusHooks';
|
||||
import { renderWithProviders } from '../../test/test-utils';
|
||||
import Skills from '../Skills';
|
||||
|
||||
vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/skillsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
|
||||
'../../services/api/skillsApi'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../lib/composio/hooks', () => ({
|
||||
useComposioIntegrations: () => ({
|
||||
toolkits: [],
|
||||
connectionByToolkit: new Map(),
|
||||
refresh: vi.fn(),
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
useAgentReadyComposioToolkits: () => ({
|
||||
agentReady: new Set<string>(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Skills page — MCP tab', () => {
|
||||
it('shows a coming soon placeholder for MCP server management', () => {
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
|
||||
|
||||
expect(screen.getAllByRole('heading', { name: 'MCP Servers' })).toHaveLength(2);
|
||||
expect(screen.getByText(/MCP server management is coming soon/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Coming Soon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
setCloudProviderKey,
|
||||
setLocalRuntimeEnabled,
|
||||
setOpenAICompatEndpointKey,
|
||||
testProviderModel,
|
||||
} from '../aiSettingsApi';
|
||||
|
||||
// ─── Mock declarations (must be hoisted before imports) ───────────────────────
|
||||
@@ -809,6 +810,35 @@ describe('listProviderModels', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('testProviderModel', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('dispatches openhuman.inference_test_provider_model and returns the reply', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { reply: 'Hello from model' } });
|
||||
|
||||
const result = await testProviderModel('reasoning', 'openai:gpt-4o');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.inference_test_provider_model',
|
||||
params: { workload: 'reasoning', provider: 'openai:gpt-4o', prompt: 'Hello world' },
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
expect(result).toEqual({ reply: 'Hello from model' });
|
||||
});
|
||||
|
||||
it('throws when not running in Tauri', async () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
await expect(testProviderModel('reasoning', 'openai:gpt-4o')).rejects.toThrow(
|
||||
'Model testing is only available in the desktop app.'
|
||||
);
|
||||
expect(mockCallCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── flushCloudProviders ──────────────────────────────────────────────────────
|
||||
|
||||
describe('flushCloudProviders', () => {
|
||||
|
||||
@@ -113,6 +113,12 @@ export interface ModelInfo {
|
||||
context_window?: number | null;
|
||||
}
|
||||
|
||||
export interface ProviderModelTestResult {
|
||||
reply: string;
|
||||
}
|
||||
|
||||
const PROVIDER_MODEL_TEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Single in-memory snapshot the AI panel renders against. */
|
||||
export interface AISettings {
|
||||
cloudProviders: CloudProviderView[];
|
||||
@@ -371,6 +377,27 @@ export async function listProviderModels(providerId: string): Promise<ModelInfo[
|
||||
return res?.result?.models ?? [];
|
||||
}
|
||||
|
||||
export async function testProviderModel(
|
||||
workload: WorkloadId,
|
||||
provider: string,
|
||||
prompt = 'Hello world'
|
||||
): Promise<ProviderModelTestResult> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Model testing is only available in the desktop app.');
|
||||
}
|
||||
const res = await callCoreRpc<{ result: ProviderModelTestResult }>({
|
||||
method: 'openhuman.inference_test_provider_model',
|
||||
params: { workload, provider, prompt },
|
||||
timeoutMs: PROVIDER_MODEL_TEST_TIMEOUT_MS,
|
||||
});
|
||||
if (!res?.result) {
|
||||
throw new Error(
|
||||
`Model test RPC returned no result for ${workload} via ${provider} (openhuman.inference_test_provider_model).`
|
||||
);
|
||||
}
|
||||
return res.result;
|
||||
}
|
||||
|
||||
// ─── Local provider façade (Ollama install / detect / model manage) ───────
|
||||
|
||||
/** Snapshot of the Ollama daemon + installed-model state for the AI panel. */
|
||||
|
||||
@@ -187,6 +187,51 @@ pub(crate) struct LmStudioChatChoice {
|
||||
pub(crate) struct LmStudioChatResponseMessage {
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
impl LmStudioChatResponseMessage {
|
||||
pub(crate) fn effective_content(&self) -> String {
|
||||
let content = self
|
||||
.content
|
||||
.as_deref()
|
||||
.map(crate::openhuman::inference::provider::compatible_parse::strip_think_tags)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default();
|
||||
if !content.is_empty() {
|
||||
tracing::trace!(
|
||||
source = "content",
|
||||
output_chars = content.chars().count(),
|
||||
"[lm-studio] effective content selected"
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
let reasoning = self
|
||||
.reasoning_content
|
||||
.as_deref()
|
||||
.map(crate::openhuman::inference::provider::compatible_parse::strip_think_tags)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default();
|
||||
if !reasoning.is_empty() {
|
||||
tracing::trace!(
|
||||
source = "reasoning_content",
|
||||
output_chars = reasoning.chars().count(),
|
||||
"[lm-studio] effective content selected"
|
||||
);
|
||||
return reasoning;
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
source = "none",
|
||||
output_chars = 0,
|
||||
"[lm-studio] effective content empty"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -228,4 +273,22 @@ mod tests {
|
||||
Some("http://127.0.0.1:1234/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_content_falls_back_to_reasoning_content() {
|
||||
let msg = LmStudioChatResponseMessage {
|
||||
content: Some("".into()),
|
||||
reasoning_content: Some("thinking text".into()),
|
||||
};
|
||||
assert_eq!(msg.effective_content(), "thinking text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_content_strips_think_tags() {
|
||||
let msg = LmStudioChatResponseMessage {
|
||||
content: Some("<think>hidden</think>Visible reply".into()),
|
||||
reasoning_content: None,
|
||||
};
|
||||
assert_eq!(msg.effective_content(), "Visible reply");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,10 +225,8 @@ impl LocalAiService {
|
||||
let reply = payload
|
||||
.choices
|
||||
.first()
|
||||
.and_then(|choice| choice.message.content.as_deref())
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
.map(|choice| choice.message.effective_content())
|
||||
.unwrap_or_default();
|
||||
|
||||
if reply.is_empty() && !allow_empty {
|
||||
return Err("lm studio returned empty content".to_string());
|
||||
|
||||
@@ -236,6 +236,45 @@ async fn lm_studio_chat_with_history_returns_response() {
|
||||
assert_eq!(reply, "history reply");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lm_studio_chat_with_history_falls_back_to_reasoning_content() {
|
||||
let _guard = crate::openhuman::inference::inference_test_guard();
|
||||
|
||||
let app = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
post(|Json(_body): Json<serde_json::Value>| async move {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "reasoning-only reply"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let base = spawn_mock(app).await;
|
||||
let config = lm_studio_config(&base);
|
||||
let service = ready_service(&config);
|
||||
|
||||
let reply = service
|
||||
.chat_with_history(
|
||||
&config,
|
||||
vec![
|
||||
crate::openhuman::inference::local::ollama::OllamaChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "hi".to_string(),
|
||||
},
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("lm studio reasoning fallback");
|
||||
|
||||
assert_eq!(reply, "reasoning-only reply");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lm_studio_prompt_errors_on_non_success_status() {
|
||||
let _guard = crate::openhuman::inference::inference_test_guard();
|
||||
|
||||
@@ -13,6 +13,11 @@ use tracing::{debug, error};
|
||||
|
||||
const LOG_PREFIX: &str = "[inference::ops]";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct InferenceTestProviderModelResult {
|
||||
pub reply: String,
|
||||
}
|
||||
|
||||
pub async fn inference_status(config: &Config) -> Result<RpcOutcome<LocalAiStatus>, String> {
|
||||
debug!("{LOG_PREFIX} status:start");
|
||||
let result = local_runtime::rpc::local_ai_status(config).await;
|
||||
@@ -123,6 +128,96 @@ pub async fn inference_chat(
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn inference_test_provider_model(
|
||||
config: &Config,
|
||||
workload: &str,
|
||||
provider: &str,
|
||||
prompt: &str,
|
||||
) -> Result<RpcOutcome<InferenceTestProviderModelResult>, String> {
|
||||
debug!(
|
||||
workload,
|
||||
provider,
|
||||
prompt_len = prompt.len(),
|
||||
"{LOG_PREFIX} test_provider_model:start"
|
||||
);
|
||||
let result =
|
||||
if provider.trim().starts_with("lmstudio:") || provider.trim().starts_with("ollama:") {
|
||||
let mut effective = config.clone();
|
||||
let (local_kind, raw_model) = provider
|
||||
.split_once(':')
|
||||
.ok_or_else(|| "invalid local provider string".to_string())?;
|
||||
let (model, temperature_override) = match raw_model.rsplit_once('@') {
|
||||
Some((head, tail)) => match tail.trim().parse::<f64>() {
|
||||
Ok(temp) if !head.trim().is_empty() => (head.trim().to_string(), Some(temp)),
|
||||
_ => (raw_model.trim().to_string(), None),
|
||||
},
|
||||
None => (raw_model.trim().to_string(), None),
|
||||
};
|
||||
if model.is_empty() {
|
||||
return Err("model must not be empty".to_string());
|
||||
}
|
||||
if let Some(temp) = temperature_override {
|
||||
effective.default_temperature = temp;
|
||||
}
|
||||
if local_kind == "lmstudio" {
|
||||
effective.local_ai.provider = "lm_studio".to_string();
|
||||
if let Some(entry) = config.cloud_providers.iter().find(|e| e.slug == "lmstudio") {
|
||||
effective.local_ai.base_url = Some(entry.endpoint.clone());
|
||||
}
|
||||
} else {
|
||||
effective.local_ai.provider = "ollama".to_string();
|
||||
if let Some(entry) = config.cloud_providers.iter().find(|e| e.slug == "ollama") {
|
||||
effective.local_ai.base_url = Some(
|
||||
entry
|
||||
.endpoint
|
||||
.trim_end_matches("/")
|
||||
.trim_end_matches("/v1")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
effective.local_ai.chat_model_id = model;
|
||||
let messages = vec![LocalAiChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: prompt.to_string(),
|
||||
}];
|
||||
local_runtime::rpc::local_ai_chat(&effective, messages, None)
|
||||
.await
|
||||
.map(|outcome| {
|
||||
RpcOutcome::single_log(
|
||||
InferenceTestProviderModelResult {
|
||||
reply: outcome.value,
|
||||
},
|
||||
"provider model test completed",
|
||||
)
|
||||
})
|
||||
} else {
|
||||
let (chat_provider, model) =
|
||||
crate::openhuman::inference::provider::factory::create_chat_provider_from_string(
|
||||
workload, provider, config,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
chat_provider
|
||||
.simple_chat(prompt, &model, config.default_temperature)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.map(|reply| {
|
||||
RpcOutcome::single_log(
|
||||
InferenceTestProviderModelResult { reply },
|
||||
"provider model test completed",
|
||||
)
|
||||
})
|
||||
};
|
||||
match &result {
|
||||
Ok(outcome) => debug!(
|
||||
output_len = outcome.value.reply.len(),
|
||||
"{LOG_PREFIX} test_provider_model:ok"
|
||||
),
|
||||
Err(err) => error!(error = %err, "{LOG_PREFIX} test_provider_model:error"),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn inference_should_react(
|
||||
config: &Config,
|
||||
message: &str,
|
||||
|
||||
@@ -63,6 +63,15 @@ async fn inference_chat_rejects_empty_messages() {
|
||||
assert!(err.contains("must not be empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_test_provider_model_uses_local_runtime_branch_for_lmstudio_prefix() {
|
||||
let (config, _tmp) = disabled_config();
|
||||
let err = inference_test_provider_model(&config, "reasoning", "lmstudio:test-model", "Hello")
|
||||
.await
|
||||
.expect_err("lmstudio local test should fail when local ai is disabled");
|
||||
assert!(err.contains("local ai is disabled"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inference_should_react_short_circuits_for_empty_message() {
|
||||
let (config, _tmp) = disabled_config();
|
||||
|
||||
@@ -44,6 +44,13 @@ struct InferenceChatParams {
|
||||
max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InferenceTestProviderModelParams {
|
||||
workload: String,
|
||||
provider: String,
|
||||
prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InferenceShouldReactParams {
|
||||
message: String,
|
||||
@@ -147,6 +154,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("vision_prompt"),
|
||||
schemas("embed"),
|
||||
schemas("chat"),
|
||||
schemas("test_provider_model"),
|
||||
schemas("should_react"),
|
||||
schemas("analyze_sentiment"),
|
||||
]
|
||||
@@ -226,6 +234,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("chat"),
|
||||
handler: handle_inference_chat,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("test_provider_model"),
|
||||
handler: handle_inference_test_provider_model,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("should_react"),
|
||||
handler: handle_inference_should_react,
|
||||
@@ -434,6 +446,17 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
],
|
||||
outputs: vec![json_output("reply", "Assistant reply text.")],
|
||||
},
|
||||
"test_provider_model" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "test_provider_model",
|
||||
description: "Run a one-off Hello-world style test against an explicit provider:model binding without saving routing changes.",
|
||||
inputs: vec![
|
||||
required_string("workload", "Workload id context (chat, reasoning, coding, etc.)."),
|
||||
required_string("provider", "Explicit provider string like 'openai:gpt-4o' or 'ollama:llama3.1:8b'."),
|
||||
optional_string("prompt", "Optional prompt text to send; defaults to 'Hello world'."),
|
||||
],
|
||||
outputs: vec![json_output("reply", "Assistant reply text.")],
|
||||
},
|
||||
"should_react" => ControllerSchema {
|
||||
namespace: "inference",
|
||||
function: "should_react",
|
||||
@@ -804,6 +827,22 @@ fn handle_inference_chat(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_test_provider_model(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<InferenceTestProviderModelParams>(params)?;
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(
|
||||
crate::openhuman::inference::rpc::inference_test_provider_model(
|
||||
&config,
|
||||
&p.workload,
|
||||
&p.provider,
|
||||
p.prompt.as_deref().unwrap_or("Hello world"),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_inference_should_react(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<InferenceShouldReactParams>(params)?;
|
||||
|
||||
Reference in New Issue
Block a user