Add a real pre-flight to status
Slopo reports a bad embedding configuration as the two words "Embedding failed." on stdout, with the real reason in its agent log. That is a slow way to learn that a model string has the wrong provider prefix, or that the dimensions do not match what the model actually returns — the second of which is expensive, because slopo freezes the dimensions into the index at first use. `status` now does three things instead of one: it reports the resolved settings, it runs `slopo --version` through the shell seam, and it makes one tiny embedding request against the configured endpoint and compares the returned width with the configured one. Verified against a real Ollama: a correct config reports ok, a dimension mismatch names both numbers, and a wrong model returns the endpoint's own 404 rather than a silent failure minutes into an embed. The probe is a direct fetch rather than a shell call, because it is one small HTTP request and does not belong behind a boundary that exists to confine filesystem writes. Two smaller things this needed: - the sandbox policy is now resolved once at the top of execute, so `status` can use it without a second resolution - provider-prefix stripping is an allow-list, not "drop the first segment". `unclemusclez/jina-embeddings-v2-base-code:latest` is a real model name with no prefix at all, and an unknown first segment is left alone so a wrong guess is visible in the reported `sentModel` rather than silent
This commit is contained in:
1 parent
e758a723e2
commit
d7eccc6366
2 files changed
+243
-13
No files matched your search
@@ -33,10 +33,9 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|||||||
import Schema from '@deepseek-ai/schemastery'
|
import Schema from '@deepseek-ai/schemastery'
|
||||||
|
|
||||||
import { missingRequired, normalizeConfig, renderSlopoConfig } from './lib/config.js'
|
import { missingRequired, normalizeConfig, renderSlopoConfig } from './lib/config.js'
|
||||||
|
import { probeEmbeddingEndpoint } from './lib/probe.js'
|
||||||
import {
|
import {
|
||||||
PLUGIN_NAME,
|
|
||||||
resolveDshHome,
|
resolveDshHome,
|
||||||
workspaceKey,
|
|
||||||
workspaceStateDir,
|
workspaceStateDir,
|
||||||
} from './lib/paths.js'
|
} from './lib/paths.js'
|
||||||
import {
|
import {
|
||||||
@@ -423,6 +422,14 @@ export function apply(ctx, config) {
|
|||||||
const settings = readSettings() ?? config
|
const settings = readSettings() ?? config
|
||||||
const paths = workspacePaths(dshHome, cwd, settings)
|
const paths = workspacePaths(dshHome, cwd, settings)
|
||||||
const shell = ctx.get('shell')
|
const shell = ctx.get('shell')
|
||||||
|
// Resolved once, up front, so `status` can probe without a second
|
||||||
|
// resolution. The NO_SANDBOX_POLICY refusal stays with the actions that
|
||||||
|
// actually execute something.
|
||||||
|
const sandboxDefault = shell?.sandboxMode
|
||||||
|
const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy')
|
||||||
|
const sandboxPolicy = policyService?.resolve(
|
||||||
|
exec.agent === undefined ? {} : { session: exec.agent.session },
|
||||||
|
)
|
||||||
|
|
||||||
const foreground = (value) => ({ kind: 'foreground', action: args.action, configPath: paths.configPath, logPath: paths.logFile, ...value })
|
const foreground = (value) => ({ kind: 'foreground', action: args.action, configPath: paths.configPath, logPath: paths.logFile, ...value })
|
||||||
|
|
||||||
@@ -431,8 +438,65 @@ export function apply(ctx, config) {
|
|||||||
const problems = []
|
const problems = []
|
||||||
const slopoConfig = toSlopoConfig(settings, paths, problems)
|
const slopoConfig = toSlopoConfig(settings, paths, problems)
|
||||||
const missing = missingRequired(slopoConfig)
|
const missing = missingRequired(slopoConfig)
|
||||||
|
|
||||||
|
// Two pre-flight checks, because slopo reports a bad embedding
|
||||||
|
// configuration as the two words "Embedding failed." on stdout with
|
||||||
|
// the real reason buried in its agent log. That is a slow way to
|
||||||
|
// discover a wrong provider prefix or a dimension mismatch.
|
||||||
|
let slopoVersion
|
||||||
|
if (binary !== undefined && shell !== undefined) {
|
||||||
|
try {
|
||||||
|
const versionResult = await shell.run(
|
||||||
|
shell.resolve({
|
||||||
|
command: commandLine([binary, '--version']),
|
||||||
|
workdir: paths.cwd,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
slopoVersion =
|
||||||
|
versionResult.exitCode === 0
|
||||||
|
? (versionResult.stdout?.text ?? '').trim().split('\n')[0]
|
||||||
|
: `exit code ${versionResult.exitCode}: ${(versionResult.stderr?.text ?? '').trim().slice(0, 200)}`
|
||||||
|
} catch (error) {
|
||||||
|
slopoVersion = `could not run: ${error?.message ?? error}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A direct fetch rather than the shell seam: the probe is one small
|
||||||
|
// HTTP request and does not belong behind a sandbox boundary that
|
||||||
|
// exists to confine filesystem writes.
|
||||||
|
const endpoint =
|
||||||
|
missing.length === 0
|
||||||
|
? await probeEmbeddingEndpoint({
|
||||||
|
apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '',
|
||||||
|
model: String(slopoConfig.embedding_model ?? ''),
|
||||||
|
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
|
||||||
|
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
|
||||||
|
})
|
||||||
|
: { checked: false, skipped: 'nothing is configured yet' }
|
||||||
|
|
||||||
|
let message
|
||||||
|
if (missing.length > 0) {
|
||||||
|
message =
|
||||||
|
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
||||||
|
'to set embeddingModel and embeddingDimensions, then "prepare".'
|
||||||
|
} else if (endpoint.ok === false) {
|
||||||
|
message =
|
||||||
|
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
|
||||||
|
`(probed ${endpoint.url} with model "${endpoint.sentModel}")`
|
||||||
|
} else if (endpoint.checked === true) {
|
||||||
|
message =
|
||||||
|
`Ready. ${binary ?? 'slopo'} will use "${slopoConfig.embedding_model}" at ` +
|
||||||
|
`${Number(slopoConfig.embedding_dimensions)} dimensions, confirmed against ${endpoint.url}.`
|
||||||
|
} else {
|
||||||
|
message =
|
||||||
|
`Configured against "${slopoConfig.embedding_model}" at ` +
|
||||||
|
`${Number(slopoConfig.embedding_dimensions)} dimensions. Nothing was probed: ${endpoint.skipped}.`
|
||||||
|
}
|
||||||
|
|
||||||
return foreground({
|
return foreground({
|
||||||
ok: missing.length === 0,
|
ok: missing.length === 0 && endpoint.ok !== false,
|
||||||
exitCode: null,
|
exitCode: null,
|
||||||
details: {
|
details: {
|
||||||
dshHome,
|
dshHome,
|
||||||
@@ -443,19 +507,17 @@ export function apply(ctx, config) {
|
|||||||
ignoreFile: existsSync(paths.ignoreFile) ? paths.ignoreFile : null,
|
ignoreFile: existsSync(paths.ignoreFile) ? paths.ignoreFile : null,
|
||||||
agentLog: existsSync(paths.logFile) ? paths.logFile : null,
|
agentLog: existsSync(paths.logFile) ? paths.logFile : null,
|
||||||
slopoBinary: binary ?? null,
|
slopoBinary: binary ?? null,
|
||||||
|
slopoVersion: slopoVersion ?? null,
|
||||||
slopoInstalledInto: binary === undefined ? null : dshHome,
|
slopoInstalledInto: binary === undefined ? null : dshHome,
|
||||||
settingsNamespace: writeSettings === undefined ? null : SETTINGS_NAMESPACE,
|
settingsNamespace: writeSettings === undefined ? null : SETTINGS_NAMESPACE,
|
||||||
embeddingModel: slopoConfig.embedding_model ?? null,
|
embeddingModel: slopoConfig.embedding_model ?? null,
|
||||||
embeddingDimensions: slopoConfig.embedding_dimensions ?? null,
|
embeddingDimensions: slopoConfig.embedding_dimensions ?? null,
|
||||||
apiKeyConfigured: typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '',
|
apiKeyConfigured: typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '',
|
||||||
|
embeddingEndpoint: endpoint,
|
||||||
missingSettings: missing,
|
missingSettings: missing,
|
||||||
problems,
|
problems,
|
||||||
},
|
},
|
||||||
message:
|
message,
|
||||||
missing.length === 0
|
|
||||||
? `${binary === undefined ? 'slopo is not installed yet; it will be installed on first use.' : `slopo: ${binary}`}`
|
|
||||||
: `Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
|
||||||
'to set embeddingModel and embeddingDimensions, then "prepare".',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,8 +567,6 @@ export function apply(ctx, config) {
|
|||||||
// preset. That confines a call the session asked to run unconfined, and
|
// preset. That confines a call the session asked to run unconfined, and
|
||||||
// on a host with no sandbox runner it fails with SandboxUnavailableError
|
// on a host with no sandbox runner it fails with SandboxUnavailableError
|
||||||
// before slopo is ever reached.
|
// before slopo is ever reached.
|
||||||
const sandboxDefault = shell.sandboxMode
|
|
||||||
const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy')
|
|
||||||
if (sandboxDefault !== undefined && policyService === undefined) {
|
if (sandboxDefault !== undefined && policyService === undefined) {
|
||||||
return foreground({
|
return foreground({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -516,9 +576,6 @@ export function apply(ctx, config) {
|
|||||||
remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.',
|
remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const sandboxPolicy = policyService?.resolve(
|
|
||||||
exec.agent === undefined ? {} : { session: exec.agent.session },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Slopo's state is deliberately outside the workspace, which a
|
// Slopo's state is deliberately outside the workspace, which a
|
||||||
// workspace-write sandbox forbids writing to. Say so before a run and
|
// workspace-write sandbox forbids writing to. Say so before a run and
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Pre-flight checks for the embedding backend.
|
||||||
|
*
|
||||||
|
* Slopo reports a bad embedding configuration as two words on stdout —
|
||||||
|
* `Error: Embedding failed.` — with the real reason buried in its agent log.
|
||||||
|
* That is a slow way to find out that a model string has the wrong provider
|
||||||
|
* prefix or that the configured dimensions do not match what the model returns.
|
||||||
|
*
|
||||||
|
* These checks run *before* a real embed so the answer arrives in one tool call
|
||||||
|
* instead of after a failed index.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LiteLLM provider prefixes we are confident are prefixes.
|
||||||
|
*
|
||||||
|
* This allow-list exists because the alternative — stripping the first path
|
||||||
|
* segment unconditionally — mangles a model that legitimately contains a slash.
|
||||||
|
* `unclemusclez/jina-embeddings-v2-base-code:latest` is a real Ollama model name
|
||||||
|
* with no provider prefix at all, and stripping it would produce nonsense. An
|
||||||
|
* unknown first segment is therefore left alone, which fails safe: the probe
|
||||||
|
* reports what it sent, so a wrong guess is visible rather than silent.
|
||||||
|
*/
|
||||||
|
const KNOWN_PROVIDERS = new Set([
|
||||||
|
'openai',
|
||||||
|
'openai_like',
|
||||||
|
'ollama',
|
||||||
|
'ollama_chat',
|
||||||
|
'hosted_vllm',
|
||||||
|
'azure',
|
||||||
|
'azure_ai',
|
||||||
|
'cohere',
|
||||||
|
'voyage',
|
||||||
|
'jina_ai',
|
||||||
|
'mistral',
|
||||||
|
'gemini',
|
||||||
|
'vertex_ai',
|
||||||
|
'bedrock',
|
||||||
|
'huggingface',
|
||||||
|
'together_ai',
|
||||||
|
'deepinfra',
|
||||||
|
'openrouter',
|
||||||
|
'xinference',
|
||||||
|
'nvidia_nim',
|
||||||
|
'sagemaker',
|
||||||
|
'watsonx',
|
||||||
|
'databricks',
|
||||||
|
'custom',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model name an OpenAI-compatible endpoint expects, without LiteLLM's
|
||||||
|
* provider prefix.
|
||||||
|
*
|
||||||
|
* @param {string} model
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function bareModelName(model) {
|
||||||
|
const trimmed = String(model).trim()
|
||||||
|
const slash = trimmed.indexOf('/')
|
||||||
|
if (slash <= 0) return trimmed
|
||||||
|
const prefix = trimmed.slice(0, slash)
|
||||||
|
return KNOWN_PROVIDERS.has(prefix) ? trimmed.slice(slash + 1) : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `/embeddings` URL for a configured base, tolerating a base that already
|
||||||
|
* carries the version segment.
|
||||||
|
*
|
||||||
|
* @param {string} apiBase
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function embeddingUrl(apiBase) {
|
||||||
|
const base = String(apiBase).trim().replace(/\/+$/, '')
|
||||||
|
if (base === '') return ''
|
||||||
|
return /\/v\d+$/.test(base) ? `${base}/embeddings` : `${base}/v1/embeddings`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make one embedding request and compare the returned width with the
|
||||||
|
* configured one.
|
||||||
|
*
|
||||||
|
* Never throws: every failure is a reported outcome, because this runs inside a
|
||||||
|
* status check whose whole job is to describe what is wrong.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* apiBase: string,
|
||||||
|
* model: string,
|
||||||
|
* dimensions: number,
|
||||||
|
* apiKey?: string,
|
||||||
|
* timeoutMs?: number,
|
||||||
|
* fetchImpl?: typeof fetch,
|
||||||
|
* }} input
|
||||||
|
* @returns {Promise<Record<string, unknown>>}
|
||||||
|
*/
|
||||||
|
export async function probeEmbeddingEndpoint({
|
||||||
|
apiBase,
|
||||||
|
model,
|
||||||
|
dimensions,
|
||||||
|
apiKey = '',
|
||||||
|
timeoutMs = 8_000,
|
||||||
|
fetchImpl = globalThis.fetch,
|
||||||
|
}) {
|
||||||
|
if (typeof apiBase !== 'string' || apiBase.trim() === '') {
|
||||||
|
return { checked: false, skipped: 'no embeddingApiBase configured, so there is nothing to probe' }
|
||||||
|
}
|
||||||
|
if (typeof fetchImpl !== 'function') {
|
||||||
|
return { checked: false, skipped: 'this runtime has no fetch' }
|
||||||
|
}
|
||||||
|
const url = embeddingUrl(apiBase)
|
||||||
|
const sentModel = bareModelName(model)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
const headers = { 'content-type': 'application/json' }
|
||||||
|
if (apiKey !== '') headers.authorization = `Bearer ${apiKey}`
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ model: sentModel, input: ['slopo connectivity probe'] }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
checked: true,
|
||||||
|
reachable: true,
|
||||||
|
ok: false,
|
||||||
|
url,
|
||||||
|
sentModel,
|
||||||
|
status: response.status,
|
||||||
|
error: text.slice(0, 400),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { checked: true, reachable: true, ok: false, url, sentModel, error: 'the endpoint returned non-JSON' }
|
||||||
|
}
|
||||||
|
const vector = parsed?.data?.[0]?.embedding
|
||||||
|
if (!Array.isArray(vector)) {
|
||||||
|
return { checked: true, reachable: true, ok: false, url, sentModel, error: 'the response carried no embedding' }
|
||||||
|
}
|
||||||
|
const actual = vector.length
|
||||||
|
const expected = Number(dimensions)
|
||||||
|
return {
|
||||||
|
checked: true,
|
||||||
|
reachable: true,
|
||||||
|
ok: actual === expected,
|
||||||
|
url,
|
||||||
|
sentModel,
|
||||||
|
dimensions: actual,
|
||||||
|
expected,
|
||||||
|
error:
|
||||||
|
actual === expected
|
||||||
|
? undefined
|
||||||
|
: `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const aborted = error?.name === 'AbortError'
|
||||||
|
return {
|
||||||
|
checked: true,
|
||||||
|
reachable: false,
|
||||||
|
ok: false,
|
||||||
|
url,
|
||||||
|
sentModel,
|
||||||
|
error: aborted ? `no response within ${timeoutMs}ms` : String(error?.message ?? error),
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user