mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
feat(custom-model): generate Run-menu entries from saved endpoint profiles
Follow-up to #393, picking up the work Ark0N invited in his merge comment: "generate those entries from the saved profiles rather than a fixed duplicate per harness, and put it in a follow-up PR so this one stays the backend... The Run-menu picker is yours if you want it." Adds the frontend surface the backend has been waiting on: - Run menu: a "Custom Endpoints" section lists one entry per (harness that supports customModelInjection, saved endpoint) pair, e.g. "Claude Code (llama.cpp)". The harness list comes from window.__codemanCustomModelClis, injected at page render straight off the CLI registry's own capabilities (never a hardcoded id list in the frontend), so a CLI whose injection recipe lands later appears with no frontend change. Picking an entry runs that harness's own existing run*() function unmodified (case creation, env overrides, everything, forced to a single instance) and then applies the endpoint's default model to the session it creates via the existing POST /api/sessions/:id/custom-model route. Entries are hidden for a remote/docker active case, since that route already refuses both. - Settings: App Settings -> Models gets a "Custom model endpoints" group wiring up the customModelEndpointsEnabled toggle (declared since #393, read by nothing until now) plus CRUD against the existing /api/model-endpoints routes: list, add/edit (inline form), delete, discover models. - Backend: CustomModelHost gains an optional defaultModelId, the model the picker applies with no further choice per endpoint (one generated menu entry per CLI+endpoint pair, not per CLI+endpoint+model). The route refuses a value that isn't one of the endpoint's own discovered models, and a fresh discovery drops a default that no longer appears rather than carrying an invalid one forward. Docs: docs/custom-model-endpoints.md describes the new picker and settings panel; CLAUDE.md's Custom Model Endpoint Profiles entry drops the "backend-only" status note and documents the picker's generation mechanism. Tests: four new route tests cover defaultModelId validation, acceptance, and the drop/keep behaviour across a re-discovery; a new render-index-html test pins the __codemanCustomModelClis injection (present, agent CLIs supporting the capability, antigravity and shell excluded) and its solo-window skip. No browser test was added for the Run-menu picker itself or the settings CRUD panel (this box has no tmux, so the live server used by test:browser/test:mobile could not be exercised here) -- worth a Playwright pass before merge, same as any other frontend PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bd286bf502
commit
25fae9ad10
@@ -40,6 +40,16 @@ export interface CustomModelHost {
|
||||
authStyle?: CustomModelAuthStyle;
|
||||
models?: string[];
|
||||
lastDiscoveredAt?: string;
|
||||
/**
|
||||
* The model the Run-menu picker (docs/custom-model-endpoints-plan.md) applies when
|
||||
* this endpoint is picked with no further choice — one generated menu entry per
|
||||
* (CLI, endpoint) pair, not per (CLI, endpoint, model), so it needs a single answer.
|
||||
* Must be a member of `models` when set; the picker falls back to `models[0]` when
|
||||
* this is unset, and disables the entry entirely when `models` is empty (nothing to
|
||||
* default to). Never auto-set on discovery — the previous default staying valid
|
||||
* after a re-discover is a property worth keeping even if the model list changes.
|
||||
*/
|
||||
defaultModelId?: string;
|
||||
}
|
||||
|
||||
export function customModelHostsPath(configDir: string): string {
|
||||
|
||||
@@ -650,6 +650,14 @@
|
||||
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
|
||||
<span class="run-mode-dot omp"></span>OMP
|
||||
</button>
|
||||
<!-- Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): one
|
||||
generated entry per (harness, saved endpoint) pair, e.g. "Claude Code
|
||||
(llama.cpp)". Built entirely by _renderCustomModelRunOptions() — hidden
|
||||
when the feature is off or no endpoint has a usable default model, never
|
||||
a fixed per-harness duplicate in this markup. -->
|
||||
<div class="run-mode-sep" id="runModeCustomModelSep" style="display: none;"></div>
|
||||
<div class="run-mode-header" id="runModeCustomModelHeader" style="display: none;">Custom Endpoints</div>
|
||||
<div class="run-mode-custom-models" id="runModeCustomModels"></div>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
@@ -2198,6 +2206,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="customModelEndpointsGroup">
|
||||
<div class="set-group-head"><h4>Custom model endpoints</h4><span class="set-scope">synced</span></div>
|
||||
<p class="set-group-hint">Point a harness at your own OpenAI-compatible server (llama.cpp, vLLM, DGX Spark, Azure AI Foundry, OpenRouter) instead of its native cloud backend. When on, the Run menu offers an extra entry per harness that supports it, per saved endpoint.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="custom model endpoint llama.cpp local llm run menu picker">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Enable custom model endpoints</span>
|
||||
<span class="set-row-desc">Adds a per-endpoint entry to the Run menu for every harness that can redirect to one.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCustomModelEndpoints"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div id="customModelHostsList" class="set-group-body" data-search="endpoints"></div>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor()">+ Add endpoint</button>
|
||||
<div id="customModelHostEditor" class="set-inline-form" style="display:none">
|
||||
<h5 id="customModelHostEditorTitle">Add endpoint</h5>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Id</span><span class="set-row-desc">Short, stable — used in URLs, never shown to the CLI.</span></div>
|
||||
<input type="text" id="customModelHostId" class="set-input" placeholder="llama-cpp-local">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Label</span></div>
|
||||
<input type="text" id="customModelHostLabel" class="set-input" placeholder="llama.cpp (local)">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Base URL</span></div>
|
||||
<input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div>
|
||||
<input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Auth header</span><span class="set-row-desc">Never send both — some servers hang indefinitely.</span></div>
|
||||
<select id="customModelHostAuthStyle" class="set-select">
|
||||
<option value="bearer">Authorization: Bearer (default)</option>
|
||||
<option value="api-key">api-key header (Azure)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Default model</span><span class="set-row-desc">What the Run-menu picker applies for this endpoint. Discover models first.</span></div>
|
||||
<select id="customModelHostDefaultModel" class="set-select" disabled></select>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.saveCustomModelHostFromEditor()">Save</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.closeCustomModelHostEditor()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Agents & CLIs ═════════════════════════════════════════ -->
|
||||
|
||||
@@ -461,6 +461,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (menu.classList.contains('active')) {
|
||||
this._loadRunModeHistory();
|
||||
this._refreshRunModeAvailability(menu);
|
||||
this._refreshCustomModelRunOptions(menu);
|
||||
const close = (ev) => {
|
||||
if (!menu.contains(ev.target)) {
|
||||
menu.classList.remove('active');
|
||||
@@ -534,6 +535,124 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates the Run menu's Custom Model Endpoint entries
|
||||
* (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved
|
||||
* endpoint) pair, e.g. "Claude Code (llama.cpp)". Hidden entirely when the
|
||||
* feature is off, no endpoint has a usable default model, or the active case is
|
||||
* remote/docker (the apply route refuses both — see session-routes.ts).
|
||||
*
|
||||
* `window.__codemanCustomModelClis` is server-injected at render time from the
|
||||
* CLI registry's own `capabilities.customModelInjection` (never a hardcoded id
|
||||
* list here), so a CLI gaining or losing the capability shows up with no
|
||||
* frontend change.
|
||||
*/
|
||||
async _refreshCustomModelRunOptions(menu) {
|
||||
const sep = menu.querySelector('#runModeCustomModelSep');
|
||||
const header = menu.querySelector('#runModeCustomModelHeader');
|
||||
const container = menu.querySelector('#runModeCustomModels');
|
||||
if (!container) return;
|
||||
const hide = () => {
|
||||
if (sep) sep.style.display = 'none';
|
||||
if (header) header.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
};
|
||||
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const capableClis = window.__codemanCustomModelClis || [];
|
||||
if (!settings.customModelEndpointsEnabled || capableClis.length === 0) return hide();
|
||||
|
||||
const caseName = document.getElementById('quickStartCase')?.value;
|
||||
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
|
||||
if (activeCase?.location === 'remote' || activeCase?.location === 'docker') return hide();
|
||||
|
||||
let hosts;
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints');
|
||||
hosts = await res.json();
|
||||
} catch {
|
||||
return hide();
|
||||
}
|
||||
if (!Array.isArray(hosts) || hosts.length === 0) return hide();
|
||||
|
||||
const rows = [];
|
||||
for (const host of hosts) {
|
||||
const modelId = host.defaultModelId || (host.models || [])[0];
|
||||
if (!modelId) continue; // nothing discovered yet — the settings panel explains why
|
||||
for (const cli of capableClis) {
|
||||
rows.push(`
|
||||
<button class="run-mode-option" data-mode="${escapeHtml(cli.id)}" data-endpoint="${escapeHtml(host.id)}"
|
||||
onclick="app.runCustomModelEntry(${JSON.stringify(cli.id)}, ${JSON.stringify(host.id)}, ${JSON.stringify(modelId)})"
|
||||
title="${escapeHtml(cli.label)} → ${escapeHtml(host.baseUrl)} (${escapeHtml(modelId)})">
|
||||
<span class="run-mode-dot ${escapeHtml(cli.id)}"></span>${escapeHtml(cli.label)} (${escapeHtml(host.label)})
|
||||
</button>`);
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return hide();
|
||||
if (sep) sep.style.display = '';
|
||||
if (header) header.style.display = '';
|
||||
container.innerHTML = rows.join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* Runs a session on `mode` and immediately applies `endpointId`/`modelId` to it
|
||||
* via POST /api/sessions/:id/custom-model (see session-routes.ts) — the same
|
||||
* restart-in-place apply path the (not-yet-built) endpoint-management surface
|
||||
* would use for an already-running session. Reuses the existing per-mode run*()
|
||||
* functions wholesale (case creation, env overrides, the works) rather than a
|
||||
* parallel create path, forcing a single instance: a custom-model run is a
|
||||
* one-off "try this endpoint" action, not a batch spawn.
|
||||
*/
|
||||
async runCustomModelEntry(mode, endpointId, modelId) {
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
const runners = {
|
||||
claude: () => this.runClaude(),
|
||||
opencode: () => this.runOpenCode(),
|
||||
codex: () => this.runCodex(),
|
||||
gemini: () => this.runGemini(),
|
||||
pi: () => this.runPi(),
|
||||
grok: () => this.runGrok(),
|
||||
deepseek: () => this.runDeepSeek(),
|
||||
omp: () => this.runOmp(),
|
||||
};
|
||||
const runner = runners[mode];
|
||||
if (!runner) {
|
||||
this.showToast(`No run function for mode ${mode}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const tabCountEl = document.getElementById('tabCount');
|
||||
const prevTabCount = tabCountEl?.value;
|
||||
if (tabCountEl) tabCountEl.value = '1';
|
||||
try {
|
||||
await runner();
|
||||
} finally {
|
||||
if (tabCountEl && prevTabCount !== undefined) tabCountEl.value = prevTabCount;
|
||||
}
|
||||
|
||||
// Every run*() ends by selecting the session it just created, so the active
|
||||
// session at this point IS the new one — see runClaude/runShell's own comments
|
||||
// on why selectSession must run before this reads activeSessionId.
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return; // run() already reported its own error via toast
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/custom-model`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpointId, modelId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showToast(`Session started on the native backend — could not apply the custom endpoint: ${data.error}`, 'warning');
|
||||
return;
|
||||
}
|
||||
this.showToast(`Pointed at ${endpointId} — restarting the session...`, 'info');
|
||||
} catch (err) {
|
||||
this.showToast(`Session started, but applying the custom endpoint failed: ${err.message}`, 'warning');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
|
||||
*
|
||||
|
||||
@@ -395,6 +395,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
|
||||
// Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both
|
||||
// the Run-menu picker's generated entries and this settings panel's visibility;
|
||||
// the endpoint list itself is server state, loaded separately below.
|
||||
document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true;
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -509,6 +513,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10;
|
||||
// Model configuration (loaded from server)
|
||||
this.loadModelConfigForSettings();
|
||||
// Custom Model Endpoint Profiles: server state, own load path (mirrors the
|
||||
// model-config pair above) rather than the settings payload — endpoints are
|
||||
// infra records (CRUD'd via /api/model-endpoints), not user preferences.
|
||||
this.loadCustomModelEndpointsForSettings();
|
||||
// Notification settings
|
||||
const notifPrefs = this.notificationManager?.preferences || {};
|
||||
document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true;
|
||||
@@ -2106,6 +2114,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
@@ -2487,6 +2496,161 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md)
|
||||
//
|
||||
// CRUD against /api/model-endpoints, rendered into the Models settings section.
|
||||
// Deliberately its own load/save pair rather than folded into openAppSettings/
|
||||
// saveAppSettings: these are server-side infra records (like remote/docker
|
||||
// hosts), not a settings-payload field, so the app-settings-structure guard's
|
||||
// by-id contract does not apply to them — only the `customModelEndpointsEnabled`
|
||||
// toggle itself goes through that path.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async loadCustomModelEndpointsForSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints');
|
||||
const hosts = await res.json();
|
||||
this._customModelHosts = Array.isArray(hosts) ? hosts : [];
|
||||
} catch (err) {
|
||||
console.warn('Failed to load model endpoints:', err);
|
||||
this._customModelHosts = this._customModelHosts || [];
|
||||
}
|
||||
this.renderCustomModelHostsList();
|
||||
},
|
||||
|
||||
renderCustomModelHostsList() {
|
||||
const list = document.getElementById('customModelHostsList');
|
||||
if (!list) return;
|
||||
const hosts = this._customModelHosts || [];
|
||||
if (hosts.length === 0) {
|
||||
list.innerHTML = '<p class="set-group-hint">No endpoints yet. Add one below to point a harness at a local or cloud OpenAI-compatible server.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = hosts
|
||||
.map((h) => {
|
||||
const modelCount = (h.models || []).length;
|
||||
const modelSummary = modelCount === 0
|
||||
? 'No models discovered yet'
|
||||
: `${modelCount} model${modelCount === 1 ? '' : 's'}${h.defaultModelId ? ` · default: ${escapeHtml(h.defaultModelId)}` : ' · no default set'}`;
|
||||
return `
|
||||
<div class="set-row" data-endpoint-id="${escapeHtml(h.id)}">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">${escapeHtml(h.label)}</span>
|
||||
<span class="set-row-desc">${escapeHtml(h.baseUrl)} — ${modelSummary}</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.discoverCustomModelHostModels(${JSON.stringify(h.id)})">Discover</button>
|
||||
<button type="button" class="btn-toolbar btn-sm" onclick="app.openCustomModelHostEditor(${JSON.stringify(h.id)})">Edit</button>
|
||||
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCustomModelHost(${JSON.stringify(h.id)})">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Opens the inline add/edit form. Pass no id to add a new endpoint. */
|
||||
openCustomModelHostEditor(hostId) {
|
||||
const host = hostId ? (this._customModelHosts || []).find((h) => h.id === hostId) : null;
|
||||
this._editingCustomModelHostId = host ? host.id : null;
|
||||
document.getElementById('customModelHostEditorTitle').textContent = host ? `Edit ${host.label}` : 'Add endpoint';
|
||||
document.getElementById('customModelHostId').value = host?.id || '';
|
||||
document.getElementById('customModelHostId').disabled = !!host; // id is immutable once created
|
||||
document.getElementById('customModelHostLabel').value = host?.label || '';
|
||||
document.getElementById('customModelHostBaseUrl').value = host?.baseUrl || '';
|
||||
document.getElementById('customModelHostApiKey').value = ''; // never round-tripped back into the field
|
||||
document.getElementById('customModelHostApiKey').placeholder = host?.apiKey ? '•••••••• (unchanged if left blank)' : '';
|
||||
document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer';
|
||||
this._populateCustomModelDefaultSelect(host);
|
||||
document.getElementById('customModelHostEditor').style.display = '';
|
||||
},
|
||||
|
||||
closeCustomModelHostEditor() {
|
||||
document.getElementById('customModelHostEditor').style.display = 'none';
|
||||
this._editingCustomModelHostId = null;
|
||||
},
|
||||
|
||||
_populateCustomModelDefaultSelect(host) {
|
||||
const select = document.getElementById('customModelHostDefaultModel');
|
||||
const models = host?.models || [];
|
||||
select.innerHTML =
|
||||
'<option value="">No default (picker uses the first discovered model)</option>' +
|
||||
models.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
|
||||
select.value = host?.defaultModelId || '';
|
||||
select.disabled = models.length === 0;
|
||||
},
|
||||
|
||||
async saveCustomModelHostFromEditor() {
|
||||
const id = document.getElementById('customModelHostId').value.trim();
|
||||
const label = document.getElementById('customModelHostLabel').value.trim();
|
||||
const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim();
|
||||
const apiKeyInput = document.getElementById('customModelHostApiKey').value;
|
||||
const authStyle = document.getElementById('customModelHostAuthStyle').value;
|
||||
const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined;
|
||||
if (!id || !label || !baseUrl) {
|
||||
this.showToast('Id, label and base URL are all required', 'warning');
|
||||
return;
|
||||
}
|
||||
const editing = this._editingCustomModelHostId;
|
||||
const existing = editing ? (this._customModelHosts || []).find((h) => h.id === editing) : null;
|
||||
const body = {
|
||||
id,
|
||||
label,
|
||||
baseUrl,
|
||||
authStyle,
|
||||
defaultModelId,
|
||||
// A blank key on EDIT means "leave it alone", never "clear it" — the field
|
||||
// is never pre-filled with the real value (see openCustomModelHostEditor),
|
||||
// so an unedited save must not silently wipe a working credential.
|
||||
apiKey: apiKeyInput ? apiKeyInput : existing?.apiKey,
|
||||
models: existing?.models,
|
||||
lastDiscoveredAt: existing?.lastDiscoveredAt,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(editing ? `/api/model-endpoints/${encodeURIComponent(editing)}` : '/api/model-endpoints', {
|
||||
method: editing ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showToast(data.error || 'Failed to save endpoint', 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(editing ? 'Endpoint updated' : 'Endpoint added', 'success');
|
||||
this.closeCustomModelHostEditor();
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Failed to save endpoint: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async discoverCustomModelHostModels(hostId) {
|
||||
this.showToast('Discovering models…', 'info');
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}/discover-models`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.showToast(data.error || 'Discovery failed', 'error');
|
||||
return;
|
||||
}
|
||||
this.showToast(`Found ${data.data.models.length} model${data.data.models.length === 1 ? '' : 's'}`, 'success');
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Discovery failed: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModelHost(hostId) {
|
||||
const host = (this._customModelHosts || []).find((h) => h.id === hostId);
|
||||
if (!confirm(`Delete endpoint "${host?.label || hostId}"? Any session currently pointed at it keeps running until cleared.`)) return;
|
||||
try {
|
||||
await fetch(`/api/model-endpoints/${encodeURIComponent(hostId)}`, { method: 'DELETE' });
|
||||
await this.loadCustomModelEndpointsForSettings();
|
||||
} catch (err) {
|
||||
this.showToast(`Failed to delete endpoint: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Visibility Settings & Device-Specific Defaults
|
||||
|
||||
@@ -16207,6 +16207,26 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles' inline add/edit form: a nested panel rather
|
||||
than a modal, so it needs its own border to read as a distinct sub-section
|
||||
inside .set-group-body's flat row stack. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-inline-form h5 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── rows ─────────────────────────────────────────────────────────────── */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {
|
||||
display: flex;
|
||||
|
||||
@@ -33,6 +33,21 @@ function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown })
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* `defaultModelId` names the model the Run-menu picker applies for this endpoint with
|
||||
* no further choice, so it must actually be one of the discovered `models` — a schema
|
||||
* `.refine()` can't see across the two fields the way this can, and would also run on
|
||||
* every unrelated field edit rather than only when either of these two changes.
|
||||
*/
|
||||
function invalidDefaultModel(host: Pick<CustomModelHost, 'defaultModelId' | 'models'>): ApiResponse<never> | null {
|
||||
if (host.defaultModelId === undefined) return null;
|
||||
if ((host.models ?? []).includes(host.defaultModelId)) return null;
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'defaultModelId must be one of the endpoint’s discovered models'
|
||||
);
|
||||
}
|
||||
|
||||
async function discoverModels(host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'>): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
const apiKey = host.apiKey?.trim();
|
||||
@@ -78,6 +93,8 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||
}
|
||||
const badDefault = invalidDefaultModel(host);
|
||||
if (badDefault) return badDefault;
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Model endpoint already exists');
|
||||
@@ -94,6 +111,8 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
if (isBlockedWebviewUrl(host.baseUrl)) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Endpoint base URL is not allowed');
|
||||
}
|
||||
const badDefault = invalidDefaultModel(host);
|
||||
if (badDefault) return badDefault;
|
||||
const hosts = await readCustomModelHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found');
|
||||
@@ -131,7 +150,12 @@ export function registerCustomModelRoutes(app: FastifyInstance): void {
|
||||
try {
|
||||
const models = await discoverModels(host);
|
||||
const next = [...hosts];
|
||||
next[index] = { ...host, models, lastDiscoveredAt: new Date().toISOString() };
|
||||
// A default that no longer appears in the fresh list would leave the Run-menu
|
||||
// picker applying a model id the endpoint just told us it doesn't serve; drop
|
||||
// it rather than carry it forward silently invalid.
|
||||
const defaultModelId =
|
||||
host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined;
|
||||
next[index] = { ...host, models, defaultModelId, lastDiscoveredAt: new Date().toISOString() };
|
||||
await writeCustomModelHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { models } };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1918,6 +1918,12 @@ export const CustomModelHostSchema = z.object({
|
||||
authStyle: z.enum(['bearer', 'api-key']).optional(),
|
||||
models: z.array(z.string().max(200)).max(200).optional(),
|
||||
lastDiscoveredAt: z.string().max(64).optional(),
|
||||
// The Run-menu picker's per-endpoint default; validated against `models` at the
|
||||
// route layer (schema-level cross-field checks can't see the array narrowed the
|
||||
// same way a `.refine()` closure could, and the route already re-reads the stored
|
||||
// host to apply it, so the check belongs there once, not duplicated into a refine
|
||||
// that would run on every unrelated field edit too).
|
||||
defaultModelId: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */
|
||||
|
||||
+13
-1
@@ -67,7 +67,7 @@ import {
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
import { getCli, enabledClis } from '../config/cli-registry/registry.js';
|
||||
import { readCustomModelHosts } from '../custom-model-hosts.js';
|
||||
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
|
||||
import type { CustomModelBookkeeping } from '../types/session.js';
|
||||
@@ -1596,6 +1596,18 @@ export class WebServer extends EventEmitter {
|
||||
'</head>',
|
||||
`<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
|
||||
);
|
||||
// Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
|
||||
// generate an entry for: read generically off the registry's `capabilities`
|
||||
// (never an id list here) so a CLI whose customModelInjection lands later shows
|
||||
// up in the picker with no frontend change, and one that ships `unsupported`
|
||||
// (antigravity, and `shell`'s `kind !== 'agent'`) never does.
|
||||
const customModelClis = enabledClis()
|
||||
.filter((entry) => entry.kind === 'agent' && entry.capabilities.customModelInjection.kind !== 'unsupported')
|
||||
.map((entry) => ({ id: entry.id, label: entry.label }));
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script>window.__codemanCustomModelClis=${JSON.stringify(customModelClis)};</script>\n</head>`
|
||||
);
|
||||
}
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
|
||||
Reference in New Issue
Block a user