mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(custom-model): pass context limits and selected model to CLIs, and add model picker filter
- opencode: write limit.context/output from the discovered context length (#749) - codex: override the native launch model when a custom endpoint is selected (#750), and set model_context_window - model picker: add a search/filter box with a no-matches line
This commit is contained in:
1 parent
f1f7eec4d6
commit
ed67596387
13 files changed
+427
-54
No files matched your search
@@ -580,7 +580,7 @@ const capabilitiesSchema = z
|
||||
kind: z.literal('configDir'),
|
||||
dirEnvVar: envName,
|
||||
fileName: z.string().min(1).max(80),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
|
||||
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml', 'deepseek-patch-json']),
|
||||
launchModel: launchModelTemplate,
|
||||
})
|
||||
.strict(),
|
||||
|
||||
@@ -841,6 +841,7 @@ const CODEX: CliEntry = {
|
||||
dirEnvVar: 'CODEX_HOME',
|
||||
fileName: 'config.toml',
|
||||
template: 'codex-toml',
|
||||
launchModel: '{modelId}',
|
||||
},
|
||||
// CODEX_HOME already matches the CODEX_ allowedPrefix above, so it was ALREADY
|
||||
// reachable via plain envOverrides before this feature existed. It is arguably
|
||||
@@ -1547,28 +1548,12 @@ const DEEPSEEK: CliEntry = {
|
||||
// privilege rather than granting it, and clamping it here was a real regression
|
||||
// (test/deepseek-mode.test.ts) fixed before this shipped.
|
||||
privilegedEnvKeys: ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'],
|
||||
// Reuses the already-existing DEEPSEEK_BASE_URL/DEEPSEEK_API_KEY keys above. No
|
||||
// modelVars — dsh's model is a profile-composition entry (see `model: { source: 'none'
|
||||
// }` above), not an env var, so forcing a specific model name may not fully work;
|
||||
// verify against a real profile before shipping.
|
||||
//
|
||||
// ⚠️ appendV1Suffix is REQUIRED, not optional-nice-to-have: without it every request
|
||||
// 404s. Confirmed live and by reading dsh's own bundled source
|
||||
// (@deepseek-ai/dsh-llm-deepseek): it builds the request URL as
|
||||
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own (its real public
|
||||
// API, https://api.deepseek.com, expects the caller's base URL to already carry any
|
||||
// needed prefix), while llama-swap/llama.cpp only serves the OpenAI-conventional
|
||||
// "/v1/chat/completions" — a bare POST to ".../chat/completions" 404s live, and the
|
||||
// 404 reported here originally ("dsh: HTTP_404: DeepSeek API error (HTTP 404)")
|
||||
// matches dsh's own error-message template for exactly this failure. See the
|
||||
// customModelInjection doc comment in cli-registry/types.ts for the full reasoning,
|
||||
// including why claude/gemini must NOT get this.
|
||||
// The installed profile supplies the transport; a per-session patch selects its exact model.
|
||||
customModelInjection: {
|
||||
kind: 'env',
|
||||
baseUrlVar: 'DEEPSEEK_BASE_URL',
|
||||
apiKeyVar: 'DEEPSEEK_API_KEY',
|
||||
modelVars: [],
|
||||
appendV1Suffix: true,
|
||||
kind: 'configDir',
|
||||
dirEnvVar: 'DSH_HOME',
|
||||
fileName: 'cordis.patch.yml',
|
||||
template: 'deepseek-patch-json',
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
|
||||
@@ -888,7 +888,7 @@ export interface CliCapabilities {
|
||||
kind: 'configDir';
|
||||
dirEnvVar: string;
|
||||
fileName: string;
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml';
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json';
|
||||
launchModel?: string;
|
||||
}
|
||||
| { kind: 'unsupported' };
|
||||
|
||||
@@ -46,6 +46,14 @@ export function applyConfigDirInjection(baseDir: string, injection: ConfigDirInj
|
||||
writeFileSync(filePath, file.content, { encoding: 'utf8', mode: 0o600 });
|
||||
chmodSync(filePath, 0o600);
|
||||
}
|
||||
if (injection.dirEnvVar === 'DSH_HOME') {
|
||||
// Reuse installed profile bundles without changing the user's profile or credentials.
|
||||
const profiles = join(baseDir, 'profiles');
|
||||
if (!existsSync(profiles)) {
|
||||
const realHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
|
||||
symlinkSync(join(realHome, 'profiles'), profiles, platform() === 'win32' ? 'junction' : 'dir');
|
||||
}
|
||||
}
|
||||
return { [injection.dirEnvVar]: baseDir, ...injection.extraEnv };
|
||||
}
|
||||
|
||||
@@ -284,5 +292,6 @@ export function applyCustomModelInjection(
|
||||
}
|
||||
const configDir = customModelConfigDir(sessionId);
|
||||
const envOverrides = applyConfigDirInjection(configDir, injection);
|
||||
|
||||
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
|
||||
}
|
||||
@@ -136,12 +136,12 @@ export function buildCustomModelInjection(
|
||||
}
|
||||
|
||||
case 'configContentEnv': {
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey);
|
||||
const content = renderConfigContent(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel({ kind: 'env', envOverrides: { [cap.envVar]: content } }, cap.launchModel, modelId);
|
||||
}
|
||||
|
||||
case 'configDir': {
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey);
|
||||
const { content, extraEnv } = renderConfigFile(cap.template, endpoint, modelId, apiKey, contextLength);
|
||||
return withLaunchModel(
|
||||
{ kind: 'configDir', dirEnvVar: cap.dirEnvVar, files: [{ relPath: cap.fileName, content }], extraEnv },
|
||||
cap.launchModel,
|
||||
@@ -164,24 +164,47 @@ function withLaunchModel<T extends EnvInjection | ConfigDirInjection>(
|
||||
return { ...result, launchModel: template.split('{modelId}').join(modelId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* One quarter of the window is held back for the reply, capped at 8192 tokens (a small local
|
||||
* model's window must not lose a disproportionate slice to the reservation). Returns undefined
|
||||
* for an unknown or non-finite size — opencode then keeps its own default guess, which is the
|
||||
* pre-fix behaviour; never invent a limit here.
|
||||
*/
|
||||
function opencodeModelLimit(contextLength: number | undefined): { context: number; output: number } | undefined {
|
||||
if (contextLength === undefined || !Number.isFinite(contextLength)) return undefined;
|
||||
const total = Math.trunc(contextLength);
|
||||
if (total <= 0) return undefined;
|
||||
const output = Math.max(1, Math.min(8192, Math.floor(total / 4)));
|
||||
return { context: total - output, output };
|
||||
}
|
||||
|
||||
function renderConfigContent(
|
||||
template: 'opencode-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): string {
|
||||
switch (template) {
|
||||
case 'opencode-json':
|
||||
case 'opencode-json': {
|
||||
// `limit` carries the discovered context window (issue #749): without it opencode assumes
|
||||
// a large default window for an unrecognized model id and eventually overflows a much
|
||||
// smaller local server. `context` is what opencode caps the PROMPT at, so the response
|
||||
// headroom from `opencodeModelLimit` is subtracted up front — `output` is opencode's own
|
||||
// cap on the reply, so setting them from the same discovered total keeps a single turn
|
||||
// inside the real window.
|
||||
const limit = opencodeModelLimit(contextLength);
|
||||
return JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
provider: {
|
||||
custom: {
|
||||
options: { baseURL: withV1Suffix(endpoint.baseUrl), apiKey },
|
||||
models: { [modelId]: {} },
|
||||
models: { [modelId]: limit ? { limit } : {} },
|
||||
},
|
||||
},
|
||||
model: `custom/${modelId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,13 +215,53 @@ const CODEX_API_KEY_ENV_VAR = 'CODEMAN_CUSTOM_MODEL_API_KEY';
|
||||
export const GROK_CUSTOM_MODEL_NAME = 'codeman-custom';
|
||||
|
||||
function renderConfigFile(
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml',
|
||||
template: 'codex-toml' | 'pi-models-json' | 'omp-models-yml' | 'grok-toml' | 'deepseek-patch-json',
|
||||
endpoint: CustomModelEndpoint,
|
||||
modelId: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
contextLength?: number
|
||||
): { content: string; extraEnv?: Record<string, string> } {
|
||||
const baseUrl = withV1Suffix(endpoint.baseUrl);
|
||||
switch (template) {
|
||||
case 'deepseek-patch-json': {
|
||||
const route = { provider: 'codeman-custom', model: modelId };
|
||||
return {
|
||||
content: JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'llm-pi-ai',
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: {
|
||||
providers: {
|
||||
'codeman-custom': {
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: baseUrl,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? {
|
||||
contextWindow: Math.trunc(contextLength),
|
||||
maxTokens: Math.max(1, Math.min(8192, Math.floor(contextLength / 4))),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: 'dsh-tui', name: '@deepseek-harness-tui/dsh-tui', config: route },
|
||||
{ id: 'agent-default-model', name: '@deepseek-ai/dsh-agent-default-model', config: route },
|
||||
],
|
||||
null,
|
||||
2
|
||||
),
|
||||
extraEnv: { DEEPSEEK_API_KEY: apiKey },
|
||||
};
|
||||
}
|
||||
case 'codex-toml': {
|
||||
// Verified against real codex (>= Feb 2026): `model` is a top-level STRING, never
|
||||
// a `[model].default` table — codex rejects that with "invalid type: map, expected
|
||||
@@ -217,6 +280,12 @@ function renderConfigFile(
|
||||
const content = [
|
||||
`model = ${quoted(modelId)}`,
|
||||
`model_provider = "custom"`,
|
||||
...(contextLength !== undefined && Number.isFinite(contextLength) && contextLength > 0
|
||||
? [
|
||||
`model_context_window = ${Math.trunc(contextLength)}`,
|
||||
`model_auto_compact_token_limit = ${Math.max(1, Math.floor(contextLength * 0.75))}`,
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
'[model_providers.custom]',
|
||||
`name = "Custom Endpoint"`,
|
||||
|
||||
@@ -1033,6 +1033,14 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelPickHint"></p>
|
||||
<!-- Case-insensitive filter over the rendered rows (app.filterCustomModelPick):
|
||||
a display layer only, the rows themselves are never re-rendered. Reset and
|
||||
focused on every open, same in-memory rule as the rail's search box. -->
|
||||
<input type="search" id="customModelPickSearch" class="session-sidebar-filter-input custom-model-pick-search"
|
||||
placeholder="Search models" aria-label="Search models"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p class="form-hint custom-model-pick-no-matches" id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<div id="customModelPickList" class="run-mode-custom-models"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1088,13 +1088,53 @@ Object.assign(CodemanApp.prototype, {
|
||||
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||
const arg = escapeHtml(JSON.stringify(m));
|
||||
// data-model-id is the filter's haystack (escapeHtml covers `"` as well as
|
||||
// `<>&`, so a hostile model id cannot break out of the attribute); the
|
||||
// filter reads it back through dataset, which decodes it.
|
||||
return `
|
||||
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<button class="run-mode-option" data-model-id="${escapeHtml(m)}" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
modal.classList.add('active');
|
||||
// A fresh open always starts unfiltered with the caret in the search box — the
|
||||
// list was just rebuilt, so clearing the input is the whole reset; focus is the
|
||||
// user's next keystroke away. Inside the generation guard above, so a
|
||||
// superseded probe can neither focus nor clear a modal that now shows another
|
||||
// endpoint's models.
|
||||
const search = document.getElementById('customModelPickSearch');
|
||||
if (search) {
|
||||
search.value = '';
|
||||
search.focus();
|
||||
}
|
||||
this.filterCustomModelPick('');
|
||||
},
|
||||
|
||||
/**
|
||||
* View filter for the open "Choose a model" picker: case-insensitive substring
|
||||
* over each row's model id, driven from the search input's own `oninput`.
|
||||
* Deliberately toggles `hidden` on the already-rendered buttons rather than
|
||||
* re-rendering from a second copy of the model list: the loaded/last-used
|
||||
* promotion, the Default pill, the escaped inline click argument and the async
|
||||
* generation guard all then stay exactly where they are, and clearing the
|
||||
* query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own
|
||||
* `display: flex` (styles.css, restated in mobile.css), which outranks the UA
|
||||
* `[hidden]` rule, so styles.css re-asserts `display: none !important` for
|
||||
* these rows — without it the filter would silently do nothing.
|
||||
*/
|
||||
filterCustomModelPick(value) {
|
||||
const list = document.getElementById('customModelPickList');
|
||||
if (!list) return;
|
||||
const query = String(value || '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const btn of list.querySelectorAll('button[data-model-id]')) {
|
||||
const match = !query || btn.dataset.modelId.toLowerCase().includes(query);
|
||||
btn.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
}
|
||||
const noMatches = document.getElementById('customModelPickNoMatches');
|
||||
if (noMatches) noMatches.hidden = visible > 0;
|
||||
},
|
||||
|
||||
closeCustomModelPickModal() {
|
||||
|
||||
@@ -7807,6 +7807,27 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The picker's search box reuses .session-sidebar-filter-input's theme-token
|
||||
styling (same reasoning as the .set-scope pill above) — only its own spacing
|
||||
and the no-matches line are added here. */
|
||||
#customModelPickModal .custom-model-pick-search {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#customModelPickModal .custom-model-pick-no-matches {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* A filtered-out row's `hidden` must actually hide it: `.run-mode-option` sets
|
||||
`display: flex` (and mobile.css restates the class in a media query), which
|
||||
outranks the UA `[hidden] { display: none }` rule, so the filter would
|
||||
silently do nothing without this re-assertion. Scoped to this modal's rows;
|
||||
the Run menu's own .run-mode-option entries never take `hidden`. */
|
||||
#customModelPickModal .run-mode-option[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||
|
||||
Reference in new issue
Block a user