/** * Plugin configuration: provider endpoint and credential reference, output * language, limits, and the external upstream runtime location. Secrets never * live here — `provider.credential` is a DSH Credential reference resolved per * operation through `ctx.credentials`. * @module dsh-vision-toolkit/config */ import z from '@deepseek-ai/schemastery' import type Schema from '@deepseek-ai/schemastery' import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import { VisionToolkitError } from './errors.ts' import { BUILT_IN_FREE_VISION_BASE_URL, BUILT_IN_FREE_VISION_CREDENTIAL, BUILT_IN_FREE_VISION_MODEL, } from './defaults.ts' export { BUILT_IN_FREE_VISION_BASE_URL, BUILT_IN_FREE_VISION_CREDENTIAL, BUILT_IN_FREE_VISION_KEY, BUILT_IN_FREE_VISION_MODEL, } from './defaults.ts' /** * The namespace pattern the removed `settingsNamespace` helper enforced. * dsh 0.1.2-alpha dropped that export; importing a missing named export is a * module-evaluation error that stops the host from booting, so the check is * inlined here instead of imported. The namespace is a static string, so no * runtime dependency on `@deepseek-ai/dsh-settings` is needed. */ const SETTINGS_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ /** Settings document namespace owned by this plugin. */ export const VISION_TOOLKIT_SETTINGS_NAMESPACE = 'vision-toolkit' as SettingsNamespace if (!SETTINGS_NAMESPACE_PATTERN.test(VISION_TOOLKIT_SETTINGS_NAMESPACE)) { throw new TypeError(`settings namespace "${VISION_TOOLKIT_SETTINGS_NAMESPACE}" must match ${String(SETTINGS_NAMESPACE_PATTERN)}`) } /** Browser-compatible default shared with the vendored Python client. */ export const DEFAULT_VISION_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' const BUILT_IN_FREE_VISION_MODEL_ALIASES = new Set([ BUILT_IN_FREE_VISION_MODEL, 'gemini-3.7-flash', 'qwen/qwen3.6-27b', 'qwen3.6-27b', 'gemma-4-26b-a4b-it', 'gemma-4-26b', '@cf/google/gemma-4-26b-a4b-it', '@cf/moondream/moondream3.1-9B-A2B', 'moondream', 'moondream-3.1', 'moondream3.1-9B-A2B', ]) /** Full user-facing configuration; every field defaults at the schema boundary. */ export interface VisionToolkitConfig { provider?: { /** Provider API base URL. */ baseUrl?: string /** DSH Credential reference holding the API key (an environment-style name). */ credential?: string /** Multimodal model name. */ model?: string /** Vision request protocol: OpenAI Chat Completions or Anthropic Messages. */ protocol?: 'openai' | 'anthropic' /** Anthropic thinking field behavior; `omit` leaves model defaults untouched. */ anthropicThinking?: 'omit' | 'disabled' | 'adaptive' /** Outbound User-Agent for provider requests and connection tests. */ userAgent?: string } /** Vision output language (`zh` or `en`). */ language?: 'zh' | 'en' /** Single remote/upstream call budget in milliseconds. */ timeoutMs?: number /** Maximum input image size in bytes; larger images are auto-compressed (lossless first). */ maxImageBytes?: number /** Maximum decoded pixel count per input image; larger images are auto-downscaled to fit. */ maxImagePixels?: number /** In-flight tool execution cap per session. */ concurrency?: number runtime?: { /** `managed` uses the packaged snapshot and isolated venv; `external` uses a clean pinned checkout. */ mode?: 'managed' | 'external' /** Required path to the clean pinned checkout when `mode` is `external`. */ agentVisionToolkitPath?: string /** Optional Python 3.11+ bootstrap/interpreter override. */ python?: string } /** * Optional shared storage root. When set, every workspace gets an isolated, * automatically generated child directory below this root instead of writing * `.dsh-vision-toolkit` into the workspace. */ storageDir?: string /** Internal read-only history used to keep persisted paths valid after storage moves. */ storageHistory?: string[] /** Extra directories (besides the workspace) inputs may come from. */ allowedDirs?: string[] /** * Image-input variants: sibling model-selector entries for every model the * host positively declares text-only. A variant declares image input, so * pasted images keep the native attachment flow (composer thumbnail and * durable session image), and the plugin rewrites image blocks into Vision * Toolkit descriptions only on the wire to the model. */ imageInputVariants?: { /** Whether variant routes are registered at all (default true). */ enabled?: boolean /** Restrict wrapped upstream routes by provider id; empty wraps every eligible route. */ providers?: string[] /** * Whether the browser paste integration automatically switches the Session * to the image-input variant of a text-only model before the paste, so * pasted images keep the native attachment flow with no manual model * change. The variant still exposes a workspace path to the model; off * keeps the path-only takeover instead (default true). */ autoSwitch?: boolean /** * Transparent routing: variant routes keep the upstream provider and model * display names, and the browser integration hides the upstream text-only * entries that have a variant twin, so the model selector shows one entry * per model and sessions stay on the image-capable variant without users * seeing or switching a `(Vision Toolkit)` route. On by default; disable * to restore the explicit sibling entries. */ hidden?: boolean } } /** Configuration schema with the documented P0 defaults. */ export const Config: Schema = z.object({ provider: z.object({ baseUrl: z.string().default(BUILT_IN_FREE_VISION_BASE_URL), credential: z.string().default(BUILT_IN_FREE_VISION_CREDENTIAL), model: z.string().default(BUILT_IN_FREE_VISION_MODEL), protocol: z.union(['openai', 'anthropic'] as const).default('openai'), anthropicThinking: z.union(['omit', 'disabled', 'adaptive'] as const).default('omit'), userAgent: z.string().default(DEFAULT_VISION_USER_AGENT), }), language: z.union(['zh', 'en'] as const).default('zh'), timeoutMs: z.number().default(30000), maxImageBytes: z.number().default(4194304), maxImagePixels: z.number().default(20000000), concurrency: z.number().default(4), runtime: z.object({ mode: z.union(['managed', 'external'] as const).default('managed'), agentVisionToolkitPath: z.string(), python: z.string(), }), storageDir: z.string(), storageHistory: z.array(z.string()).default([]), allowedDirs: z.array(z.string()).default([]), imageInputVariants: z.object({ enabled: z.boolean().default(true), providers: z.array(z.string()).default([]), autoSwitch: z.boolean().default(true), hidden: z.boolean().default(true), }), }) /** Configuration after static validation, with every default materialized. */ export interface ResolvedVisionToolkitConfig { provider: { baseUrl: string credential: CredentialRef model: string protocol: 'openai' | 'anthropic' anthropicThinking: 'omit' | 'disabled' | 'adaptive' userAgent: string } language: 'zh' | 'en' timeoutMs: number maxImageBytes: number maxImagePixels: number concurrency: number runtime: { mode: 'managed' | 'external' agentVisionToolkitPath?: string python?: string } storageDir?: string storageHistory: string[] allowedDirs: string[] imageInputVariants: { enabled: boolean providers: string[] autoSwitch: boolean hidden: boolean } } const MAX_TIMEOUT_MS = 600000 const MAX_IMAGE_BYTES = 268435456 const MAX_IMAGE_PIXELS = 268435456 const MAX_CONCURRENCY = 16 /** * Validate and normalize a config object (partial inputs receive the same * defaults the schemastery schema applies). Configuration mistakes fail loud * at plugin load (the earliest resolvable point); runtime availability is a * separate, later concern. * @param config - parsed config with defaults applied. * @returns the fully defaulted, validated configuration. */ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionToolkitConfig { const provider = config.provider ?? {} const runtime = config.runtime ?? {} const baseUrl = (provider.baseUrl ?? BUILT_IN_FREE_VISION_BASE_URL).trim().replace(/\/+$/, '') if (!/^https?:\/\//i.test(baseUrl) || baseUrl.length <= 'https://'.length) { throw new VisionToolkitError('config', 'provider.baseUrl must be an http(s) URL') } let credential: CredentialRef try { credential = credentialRef((provider.credential ?? BUILT_IN_FREE_VISION_CREDENTIAL).trim()) } catch (error) { throw new VisionToolkitError( 'config', `provider.credential "${provider.credential ?? BUILT_IN_FREE_VISION_CREDENTIAL}" is not a valid credential reference`, { cause: error }, ) } const model = (provider.model ?? BUILT_IN_FREE_VISION_MODEL).trim() if (model.length === 0) { throw new VisionToolkitError('config', 'provider.model must not be empty') } const protocol = provider.protocol ?? 'openai' if (protocol !== 'openai' && protocol !== 'anthropic') { throw new VisionToolkitError('config', 'provider.protocol must be "openai" or "anthropic"') } const anthropicThinking = provider.anthropicThinking ?? 'omit' if (anthropicThinking !== 'omit' && anthropicThinking !== 'disabled' && anthropicThinking !== 'adaptive') { throw new VisionToolkitError('config', 'provider.anthropicThinking must be "omit", "disabled", or "adaptive"') } const userAgent = (provider.userAgent ?? DEFAULT_VISION_USER_AGENT).trim() if (userAgent.length === 0) { throw new VisionToolkitError('config', 'provider.userAgent must not be empty') } const language = config.language ?? 'zh' if (language !== 'zh' && language !== 'en') { throw new VisionToolkitError('config', 'language must be "zh" or "en"') } const timeoutMs = config.timeoutMs ?? 30000 if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > MAX_TIMEOUT_MS) { throw new VisionToolkitError('config', `timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`) } const maxImageBytes = config.maxImageBytes ?? 4194304 if (!Number.isInteger(maxImageBytes) || maxImageBytes < 1024 || maxImageBytes > MAX_IMAGE_BYTES) { throw new VisionToolkitError('config', `maxImageBytes must be an integer between 1024 and ${MAX_IMAGE_BYTES}`) } const maxImagePixels = config.maxImagePixels ?? 20000000 if (!Number.isInteger(maxImagePixels) || maxImagePixels < 1 || maxImagePixels > MAX_IMAGE_PIXELS) { throw new VisionToolkitError('config', `maxImagePixels must be an integer between 1 and ${MAX_IMAGE_PIXELS}`) } const concurrency = config.concurrency ?? 4 if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) { throw new VisionToolkitError('config', `concurrency must be an integer between 1 and ${MAX_CONCURRENCY}`) } const mode = runtime.mode ?? 'managed' if (mode !== 'managed' && mode !== 'external') { throw new VisionToolkitError('config', 'runtime.mode must be "managed" or "external"') } const toolkitPath = runtime.agentVisionToolkitPath?.trim() if (toolkitPath !== undefined && toolkitPath.length === 0) { throw new VisionToolkitError('config', 'runtime.agentVisionToolkitPath must not be empty when provided') } if (mode === 'external' && toolkitPath === undefined) { throw new VisionToolkitError('config', 'runtime.agentVisionToolkitPath is required when runtime.mode is external') } if (mode === 'managed' && toolkitPath !== undefined) { throw new VisionToolkitError('config', 'runtime.agentVisionToolkitPath is only valid when runtime.mode is external') } const python = runtime.python?.trim() if (python !== undefined && python.length === 0) { throw new VisionToolkitError('config', 'runtime.python must not be empty') } const storageDir = config.storageDir?.trim() const storageHistory = [...new Set((config.storageHistory ?? []) .map(dir => dir.trim()) .filter(dir => dir.length > 0 && dir !== storageDir))] const allowedDirs = (config.allowedDirs ?? []).map(dir => dir.trim()).filter(dir => dir.length > 0) const imageInputVariants = config.imageInputVariants ?? {} const variantProviders = (imageInputVariants.providers ?? []) .map(provider => provider.trim()) .filter(provider => provider.length > 0) return { provider: { baseUrl, credential, model, protocol, anthropicThinking, userAgent }, language, timeoutMs, maxImageBytes, maxImagePixels, concurrency, runtime: { mode, ...(toolkitPath !== undefined ? { agentVisionToolkitPath: toolkitPath } : {}), ...(python !== undefined ? { python } : {}), }, ...(storageDir === undefined || storageDir.length === 0 ? {} : { storageDir }), storageHistory, allowedDirs, imageInputVariants: { enabled: imageInputVariants.enabled ?? true, providers: variantProviders, autoSwitch: imageInputVariants.autoSwitch ?? true, hidden: imageInputVariants.hidden ?? true, }, } } /** Merge prior storage roots into the next resolved generation's read-only history. */ export function retainedStorageHistory( next: VisionToolkitConfig, previous: VisionToolkitConfig, ): string[] { const resolvedNext = resolveConfig(next) const resolvedPrevious = resolveConfig(previous) return [...new Set([ ...resolvedPrevious.storageHistory, ...resolvedNext.storageHistory, ...(resolvedPrevious.storageDir === undefined ? [] : [resolvedPrevious.storageDir]), ])].filter(storageDir => storageDir !== resolvedNext.storageDir) } export interface WatchedSettingsGeneration { /** Configuration to activate now; omitted after a successful history writeback. */ config?: VisionToolkitConfig /** Whether the derived history still needs plugin-owned durable persistence. */ requiresDurableStorageHistory?: boolean /** Non-fatal internal-history persistence error. */ persistenceError?: unknown } /** Prepare one live Settings generation without letting internal history writeback block activation. */ export async function prepareWatchedSettingsGeneration( next: VisionToolkitConfig, previous: VisionToolkitConfig, writable: boolean, persistStorageHistory: (storageHistory: string[]) => Promise, ): Promise { const storageHistory = retainedStorageHistory(next, previous) if (JSON.stringify(storageHistory) === JSON.stringify(resolveConfig(next).storageHistory)) return { config: next } const config = { ...next, storageHistory } if (!writable) return { config, requiresDurableStorageHistory: true } try { await persistStorageHistory(storageHistory) return {} } catch (persistenceError) { return { config, requiresDurableStorageHistory: true, persistenceError } } } /** Whether a resolved provider should use the bundled public key instead of DSH credentials. */ export function isBuiltInFreeVisionProvider(provider: ResolvedVisionToolkitConfig['provider']): boolean { return String(provider.credential) === BUILT_IN_FREE_VISION_CREDENTIAL && provider.baseUrl === BUILT_IN_FREE_VISION_BASE_URL && BUILT_IN_FREE_VISION_MODEL_ALIASES.has(provider.model) && provider.protocol === 'openai' }