diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 78189811d8..d4db783d3b 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -56,6 +56,7 @@ import { MicrophoneLeaseRegistry } from "./live-voice/microphone-lease"; import { createLiveCallService } from "./live-voice/runtime"; import { createLiveVoiceWidget } from "./live-voice/widget-window"; import { getActiveRemoteHostsBoot } from "./bootstrap/remote-hosts"; +import { assertJevUserEndpoint } from "./jev-endpoint-policy"; import { installLiveMicrophonePermissionHandlers } from "./live-voice/microphone-permissions"; import { MainProcessState } from "./bootstrap/main-state"; import { registerApplicationActivation } from "./bootstrap/app-activation"; @@ -378,6 +379,7 @@ const createdSessionLaunchRuntime = createSessionLaunchRuntime({ bindingForModel, effectiveSubagentModelConfig, normalizeThinkingLevel, + assertJevEndpoint: assertJevUserEndpoint, }); sessionLaunchRuntime = createdSessionLaunchRuntime; const { diff --git a/apps/desktop/electron/main/ipc/jev-ipc.ts b/apps/desktop/electron/main/ipc/jev-ipc.ts index 0fce1e24f7..37d91d9ca2 100644 --- a/apps/desktop/electron/main/ipc/jev-ipc.ts +++ b/apps/desktop/electron/main/ipc/jev-ipc.ts @@ -1,12 +1,11 @@ -/** - * The Jev key check channel. - * - * One job, separate from the provider channels: Jev owns no provider row, and - * its credential lives under a fixed Host secret reference rather than a - * provider id. The handler only asks TypeSafe; it stores nothing, so a key - * that fails the check never reaches the secret store. - */ -import { IPC } from "@pi-desktop/shared"; +/** Jev System One credential check IPC. The handler stores nothing. */ +import { + DEFAULT_JEV_CLASSIFIER_BINDING, + IPC, + normalizeJevClassifierBinding, + type JevKeyCheckInput, +} from "@pi-desktop/shared"; +import { assertJevUserEndpoint } from "../jev-endpoint-policy"; import { probeJevApiKey } from "../jev-probe"; import type { Logger } from "../logger"; import type { IpcRegistrar } from "./types"; @@ -17,9 +16,22 @@ export type JevIpcDependencies = { }; export function registerJevIpc({ registrar, logger }: JevIpcDependencies): void { - registrar.handle(IPC.invoke.jevTest, async (apiKey: unknown) => { - const result = await probeJevApiKey(typeof apiKey === "string" ? apiKey : ""); - logger.app("provider", result.ok ? "info" : "warn", "TypeSafe Jev key check", { + registrar.handle(IPC.invoke.jevTest, async (input: unknown) => { + // Older renderer callers sent only the key. Keep that shape bound to the + // official endpoint while all new calls state their destination explicitly. + const legacy = typeof input === "string"; + const request = legacy ? { apiKey: input } : (input ?? {}) as Partial; + const binding = legacy + ? { ...DEFAULT_JEV_CLASSIFIER_BINDING } + : normalizeJevClassifierBinding(request.binding); + const result = binding + ? await probeJevApiKey( + typeof request.apiKey === "string" ? request.apiKey : "", + binding, + { assertEndpoint: assertJevUserEndpoint }, + ) + : { ok: false, message: "The System One endpoint or model is invalid." }; + logger.app("provider", result.ok ? "info" : "warn", "Jev System One key check", { data: { ok: result.ok, status: result.status }, }); return result; diff --git a/apps/desktop/electron/main/jev-endpoint-policy.ts b/apps/desktop/electron/main/jev-endpoint-policy.ts new file mode 100644 index 0000000000..686597ce9b --- /dev/null +++ b/apps/desktop/electron/main/jev-endpoint-policy.ts @@ -0,0 +1,23 @@ +/** Network-policy guard for a user-configured System One endpoint. */ +import { net, session } from "electron"; +import { fetchPinnedDirect } from "./public-https-direct"; +import { createPublicHttpsClient } from "./public-https-fetch"; +import { + allowInsecureUserEndpointsEnabled, + noteInsecureUserEndpoint, + relaxedNetworkPolicyEnabled, +} from "./endpoint-policy"; + +const client = createPublicHttpsClient({ + fetchImpl: (url, init) => net.fetch(url, init), + pinnedFetchImpl: (url, init, address) => fetchPinnedDirect(url, init, address), + routeImpl: (url) => session.defaultSession.resolveProxy(url), + allowFakeIp: () => relaxedNetworkPolicyEnabled(), + allowInsecureUserEndpoints: () => allowInsecureUserEndpointsEnabled(), + onInsecureUserEndpoint: (host) => noteInsecureUserEndpoint(host), +}); + +/** Apply the same user-entered endpoint policy used by other app-owned fetches. */ +export async function assertJevUserEndpoint(url: string): Promise { + await client.assertPublicUrl(url, "user"); +} diff --git a/apps/desktop/electron/main/jev-probe.ts b/apps/desktop/electron/main/jev-probe.ts index ba98305890..c3440fad16 100644 --- a/apps/desktop/electron/main/jev-probe.ts +++ b/apps/desktop/electron/main/jev-probe.ts @@ -1,43 +1,43 @@ /** - * TypeSafe reachability check for the Jev classifier key. + * Reachability check for the configured Jev System One credential. * - * Jev is configured from settings, where a wrong or expired key would - * otherwise be discovered by the Agent mid-task. The check is the smallest - * real System One round trip — one boolean question over a one-field state — - * against the same address and classifier id the Agent's `JevClassify` tool - * uses, so "the key works" means TypeSafe answered this key, not that the - * string looks plausible. - * - * Nothing is written here: the caller stores the key only when this answered. - * The key never reaches a log line or an error message; failure text is - * redacted before it is returned. + * This performs the smallest real classification request against the same + * normalized endpoint and model that Agent runtime will use. It stores nothing, + * never logs the credential, and follows redirects only inside the configured + * origin so Bearer auth cannot escape to another service. */ import { - TYPESAFE_JEV_MODEL_ID, - TYPESAFE_SYSTEM_ONE_URL, + DEFAULT_JEV_CLASSIFIER_BINDING, + jevSystemOneUrl, + normalizeJevClassifierBinding, + type JevClassifierBinding, type JevKeyCheckResult, } from "@pi-desktop/shared"; export const JEV_PROBE_TIMEOUT_MS = 20_000; - -/** Longest provider message echoed back to the settings dialog. */ const MAX_DETAIL_LENGTH = 300; +const MAX_REDIRECTS = 3; export type JevProbeOptions = { - /** The caller's own cancellation, combined with the timeout. */ signal?: AbortSignal; - /** Test seam; defaults to the global fetch. */ fetchImpl?: typeof fetch; timeoutMs?: number; + /** Production injects the app's DNS/route/network-policy guard. */ + assertEndpoint?: (url: string) => Promise; }; -/** - * The smallest valid System One request: `bool` is PI-Desktop's public name for - * the wire-level `noul` question (see pi-ai's System One transport). - */ -function probeRequestBody() { +function isProbeOptions(value: unknown): value is JevProbeOptions { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + // The legacy `(key, options)` shape allowed an empty object. New bindings + // always identify themselves with at least one binding field, including + // malformed partial bindings that must fail validation rather than fall back. + return !("baseUrl" in record || "modelId" in record); +} + +function probeRequestBody(modelId: string) { return { - model: TYPESAFE_JEV_MODEL_ID, + model: modelId, state: { probe: true }, questions: { reachable: { @@ -52,50 +52,110 @@ function probeRequestBody() { }; } -/** A message that never carries the credential it describes. */ function redact(value: string, apiKey: string): string { - if (!apiKey) return value; - return value.split(apiKey).join("[redacted]"); + return apiKey ? value.split(apiKey).join("[redacted]") : value; +} + +async function postWithSameOriginRedirects( + startUrl: string, + init: RequestInit, + fetchImpl: typeof fetch, +): Promise { + const allowedOrigin = new URL(startUrl).origin; + let url = startUrl; + for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { + const response = await fetchImpl(url, { ...init, redirect: "manual" }); + const location = response.headers?.get?.("location"); + if (response.status < 300 || response.status >= 400 || !location) return response; + const next = new URL(location, url); + if (next.origin !== allowedOrigin) { + throw Object.assign( + new Error("System One redirect refused: it left the configured origin"), + { status: response.status }, + ); + } + url = next.toString(); + } + throw new Error("System One request followed too many redirects"); } +/** + * `bindingOrOptions` keeps the original `(key, options)` test/caller shape + * compatible; new callers pass `(key, binding, options)`. + */ export async function probeJevApiKey( apiKey: string, - options: JevProbeOptions = {}, + bindingOrOptions: JevClassifierBinding | JevProbeOptions = { + ...DEFAULT_JEV_CLASSIFIER_BINDING, + }, + explicitOptions: JevProbeOptions = {}, ): Promise { + const options = isProbeOptions(bindingOrOptions) + ? bindingOrOptions + : explicitOptions; + const binding = isProbeOptions(bindingOrOptions) + ? { ...DEFAULT_JEV_CLASSIFIER_BINDING } + : bindingOrOptions; const key = apiKey.trim(); - if (!key) return { ok: false, message: "No TypeSafe API key was provided." }; + if (!key) return { ok: false, message: "No System One API key was provided." }; + const normalized = normalizeJevClassifierBinding(binding); + if (!normalized) { + return { ok: false, message: "The System One endpoint or model is invalid." }; + } + const requestUrl = jevSystemOneUrl(normalized); const timeoutMs = options.timeoutMs ?? JEV_PROBE_TIMEOUT_MS; - const timeout = AbortSignal.timeout(timeoutMs); - const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + const timeoutController = new AbortController(); + const timeoutTimer = setTimeout(() => timeoutController.abort(), timeoutMs); + const signal = options.signal + ? AbortSignal.any([options.signal, timeoutController.signal]) + : timeoutController.signal; const doFetch = options.fetchImpl ?? globalThis.fetch; try { - const response = await doFetch(TYPESAFE_SYSTEM_ONE_URL, { - method: "POST", - headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, - body: JSON.stringify(probeRequestBody()), - signal, - }); + await options.assertEndpoint?.(requestUrl); + const response = await postWithSameOriginRedirects( + requestUrl, + { + method: "POST", + headers: { + authorization: `Bearer ${key}`, + "content-type": "application/json", + }, + body: JSON.stringify(probeRequestBody(normalized.modelId)), + signal, + }, + doFetch, + ); if (response.ok) return { ok: true, status: response.status }; const body = await response.text().catch(() => ""); - const detail = redact(body.slice(0, MAX_DETAIL_LENGTH).trim(), key); + const detail = redact(body.trim(), key).slice(0, MAX_DETAIL_LENGTH); return { ok: false, status: response.status, message: detail - ? `TypeSafe returned ${response.status}: ${detail}` - : `TypeSafe returned ${response.status}`, + ? `System One returned ${response.status}: ${detail}` + : `System One returned ${response.status}`, }; } catch (error) { if (options.signal?.aborted) { - return { ok: false, message: "The TypeSafe key check was cancelled." }; + return { ok: false, message: "The System One key check was cancelled." }; } const name = (error as { name?: unknown }).name; if (name === "TimeoutError" || name === "AbortError") { return { ok: false, - message: `TypeSafe did not answer within ${Math.round(timeoutMs / 1000)}s.`, + message: `The System One endpoint did not answer within ${Math.round(timeoutMs / 1000)}s.`, }; } - return { ok: false, message: redact(error instanceof Error ? error.message : String(error), key) }; + const status = (error as { status?: unknown }).status; + return { + ok: false, + ...(typeof status === "number" ? { status } : {}), + message: redact( + error instanceof Error ? error.message : String(error), + key, + ).slice(0, MAX_DETAIL_LENGTH), + }; + } finally { + clearTimeout(timeoutTimer); } } diff --git a/apps/desktop/electron/main/runtime/provider-catalog.ts b/apps/desktop/electron/main/runtime/provider-catalog.ts index 11aee7649c..b3df450457 100644 --- a/apps/desktop/electron/main/runtime/provider-catalog.ts +++ b/apps/desktop/electron/main/runtime/provider-catalog.ts @@ -4,6 +4,8 @@ import { defaultCommandShellForPlatform, isCommandShellId, validateNetworkProxy, + normalizeJevClassifierBinding, + resolveJevClassifierBinding, validateSpeechSettings, type CommandShellId, type ModelBinding, @@ -166,14 +168,19 @@ export function createProviderCatalogRuntime({ ): T & { defaultCommandShell: CommandShellId } => { const value = ( settings && typeof settings === "object" ? settings : {} - ) as T & { defaultCommandShell?: unknown }; + ) as T & { defaultCommandShell?: unknown; jevClassifier?: unknown }; + const jev = resolveJevClassifierBinding(value.jevClassifier); return { ...(value as T), infiniteProviderRetry: (value as T & { infiniteProviderRetry?: unknown }) .infiniteProviderRetry === true, keepAwakeWhileRunning: (value as T & { keepAwakeWhileRunning?: unknown }) .keepAwakeWhileRunning === true, - jevEnabled: (value as T & { jevEnabled?: unknown }).jevEnabled === true, + jevEnabled: + (value as T & { jevEnabled?: unknown }).jevEnabled === true && + jev.status !== "invalid", + ...(jev.status === "configured" ? { jevClassifier: jev.binding } : {}), + ...(jev.status === "invalid" ? { jevClassifier: null } : {}), defaultCommandShell: isCommandShellId(value.defaultCommandShell) ? value.defaultCommandShell : defaultCommandShellForPlatform(process.platform), @@ -192,6 +199,7 @@ export function createProviderCatalogRuntime({ updatePreference?: unknown; lastNotifiedUpdateVersion?: unknown; networkProxy?: unknown; + jevClassifier?: unknown; }; if ( Object.prototype.hasOwnProperty.call(value, "defaultCommandShell") && @@ -225,6 +233,17 @@ export function createProviderCatalogRuntime({ errorCode: ErrorCodes.INVALID_PARAMS, }); } + if (Object.prototype.hasOwnProperty.call(value, "jevClassifier")) { + if (value.jevClassifier !== null) { + const binding = normalizeJevClassifierBinding(value.jevClassifier); + if (!binding) { + throw Object.assign(new Error("jevClassifier is invalid"), { + errorCode: ErrorCodes.INVALID_PARAMS, + }); + } + value.jevClassifier = binding; + } + } if ( Object.prototype.hasOwnProperty.call(value, "updatePreference") && value.updatePreference !== "automatic" && diff --git a/apps/desktop/electron/main/runtime/session-launch.ts b/apps/desktop/electron/main/runtime/session-launch.ts index 3461c2e709..65c6c53798 100644 --- a/apps/desktop/electron/main/runtime/session-launch.ts +++ b/apps/desktop/electron/main/runtime/session-launch.ts @@ -4,6 +4,8 @@ import { isActiveInProject, isCommandShellCatalog, JEV_API_KEY_SECRET_REF, + jevSystemOneUrl, + resolveJevClassifierBinding, imageGenerationBindings, isImageGenerationModel, normalizeMode, @@ -16,6 +18,7 @@ import { type SessionThinkingLevel, type UserSkillRecord, type UserSubagentRecord, + type JevClassifierRuntimeConfig, } from "@pi-desktop/shared"; import { capabilitiesFromModelConfig, @@ -79,6 +82,8 @@ export type SessionLaunchRuntimeDependencies = { capabilities: ReturnType; }; normalizeThinkingLevel: (value: unknown) => SessionThinkingLevel; + /** Re-check the user-entered classifier destination before exposing its key. */ + assertJevEndpoint?: (url: string) => Promise; }; export function createSessionLaunchRuntime({ @@ -95,6 +100,7 @@ export function createSessionLaunchRuntime({ bindingForModel, effectiveSubagentModelConfig, normalizeThinkingLevel, + assertJevEndpoint = async () => undefined, }: SessionLaunchRuntimeDependencies) { const isHostUnavailable = (error: unknown): boolean => (error as { errorCode?: string } | null | undefined)?.errorCode === @@ -679,20 +685,28 @@ export function createSessionLaunchRuntime({ const mode = normalizeMode( overrides.mode ?? session.mode ?? settings.defaultMode ?? "agent", ); - let jevApiKey: string | undefined; + let jev: JevClassifierRuntimeConfig | undefined; if (mode === "agent" && settings.jevEnabled === true) { - try { - const saved = await runtimeState.host!.call<{ value?: string | null }>( - "secrets.getForRuntime", - { secretRef: JEV_API_KEY_SECRET_REF }, - ); - if (typeof saved.value === "string" && saved.value.trim().length > 0) { - jevApiKey = saved.value; - } - } catch { - logger.app("session", "warn", "Jev API key could not be loaded", { + const resolved = resolveJevClassifierBinding(settings.jevClassifier); + if (resolved.status === "invalid") { + logger.app("session", "warn", "Jev classifier binding is invalid", { sessionId, }); + } else { + try { + await assertJevEndpoint(jevSystemOneUrl(resolved.binding)); + const saved = await runtimeState.host!.call<{ value?: string | null }>( + "secrets.getForRuntime", + { secretRef: JEV_API_KEY_SECRET_REF }, + ); + if (typeof saved.value === "string" && saved.value.trim().length > 0) { + jev = { ...resolved.binding, apiKey: saved.value }; + } + } catch { + logger.app("session", "warn", "Jev configuration could not be loaded", { + sessionId, + }); + } } } return { @@ -706,7 +720,7 @@ export function createSessionLaunchRuntime({ ...(overrides.turnId ? { turnId: overrides.turnId } : {}), thinkingLevel, infiniteProviderRetry: settings.infiniteProviderRetry === true, - ...(jevApiKey ? { jevApiKey } : {}), + ...(jev ? { jev } : {}), commandShell, scratchDir: join(dataDir, "scratch", sessionId), attachmentsDir: join(dataDir, "attachments"), diff --git a/apps/desktop/src/components/settings/JevServiceForm.tsx b/apps/desktop/src/components/settings/JevServiceForm.tsx index 030aca7cd4..4fe07ec291 100644 --- a/apps/desktop/src/components/settings/JevServiceForm.tsx +++ b/apps/desktop/src/components/settings/JevServiceForm.tsx @@ -1,39 +1,64 @@ -/** - * Adding Jev from the service list (D625). - * - * The service chooser answers "which service"; this is the whole second half - * for Jev, whose only decision is a TypeSafe key. One button runs both steps — - * check the key against TypeSafe, then store it and turn Jev on — so a key - * that never answered is never kept, and adding Jev is the same click as - * enabling it. Closing this dialog while the check is still running cancels - * the whole action: nothing is stored and nothing is enabled. - */ +/** Configure the optional Jev System One classifier from Add service. */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { + JEV_SYSTEM_ONE_API_STYLE, + normalizeJevBaseUrl, + normalizeJevClassifierBinding, + normalizeJevModelId, + type JevClassifierBinding, +} from "@pi-desktop/shared"; import { useAppStore } from "../../stores/app-store"; -import { Button, Field, PasswordInput } from "../ui"; +import { Button, Field, Input, PasswordInput } from "../ui"; import { addJevService } from "./jev-config"; import { appJevConfigDeps, useJevKeyStatus } from "./jev-app-config"; +import { SettingsMenuSelect } from "./SettingsMenuSelect"; +import { + CUSTOM_PROVIDER_SETUP_API_STYLES, + PROVIDER_SETUP_API_STYLE_LABEL_KEYS, + type ProviderSetupApiStyle, +} from "./provider-api-style"; +import { endpointLabel } from "./service-catalog"; +import { ServiceMonogram } from "./ServiceMonogram"; export type JevServiceFormProps = { onClose: () => void; - /** The key was stored and Jev turned on; the caller closes this dialog. */ onConfigured: () => void; + binding: JevClassifierBinding; + apiKey?: string; + onApiKeyChange?: (value: string) => void; + custom?: boolean; + onBindingChange?: (binding: JevClassifierBinding) => void; + onApiStyleChange?: (style: ProviderSetupApiStyle) => void; + onChangeService?: () => void; }; -export function JevServiceForm({ onClose, onConfigured }: JevServiceFormProps) { +export function JevServiceForm({ + onClose, + onConfigured, + apiKey, + onApiKeyChange, + binding, + custom = false, + onBindingChange, + onApiStyleChange, + onChangeService, +}: JevServiceFormProps) { const { t } = useTranslation(); const showToast = useAppStore((state) => state.showToast); - const [keyDraft, setKeyDraft] = useState(""); const [checking, setChecking] = useState(false); const [keyStatus] = useJevKeyStatus(); + const [localKeyDraft, setLocalKeyDraft] = useState(""); const aborted = useRef(null); const configured = keyStatus === "configured"; + const normalized = normalizeJevClassifierBinding(binding); + const validBaseUrl = normalizeJevBaseUrl(binding.baseUrl) !== undefined; + const validModelId = normalizeJevModelId(binding.modelId) !== undefined; - // Unmounting is a cancel: the check may still be in flight, and a dialog the - // user closed must not come back later with a stored credential. useEffect(() => () => aborted.current?.abort(), []); + const keyDraft = apiKey ?? localKeyDraft; + const updateKey = onApiKeyChange ?? setLocalKeyDraft; const statusText = configured ? t("settings.jevKeyConfigured") : keyStatus === "unavailable" @@ -43,14 +68,18 @@ export function JevServiceForm({ onClose, onConfigured }: JevServiceFormProps) { : t("settings.jevKeyMissing"); const save = async () => { - if (checking || !keyDraft.trim()) return; + if (checking || !keyDraft.trim() || !normalized) return; const controller = new AbortController(); aborted.current = controller; setChecking(true); try { - const outcome = await addJevService(appJevConfigDeps(), keyDraft, controller.signal); + const outcome = await addJevService( + appJevConfigDeps(), + keyDraft, + normalized, + controller.signal, + ); if (!outcome.ok) { - // Closed while the check ran: the user cancelled, so say nothing at all. if (outcome.reason === "cancelled") return; showToast( outcome.reason === "missing-key" @@ -60,7 +89,7 @@ export function JevServiceForm({ onClose, onConfigured }: JevServiceFormProps) { ); return; } - setKeyDraft(""); + updateKey(""); showToast(t("settings.jevServiceAdded"), { variant: "success" }); onConfigured(); } catch (error) { @@ -73,6 +102,10 @@ export function JevServiceForm({ onClose, onConfigured }: JevServiceFormProps) { } }; + const serviceLabel = custom + ? t("settings.presetCustomEndpoint") + : t("settings.jevOfficialService"); + return ( <>
@@ -86,7 +119,7 @@ export function JevServiceForm({ onClose, onConfigured }: JevServiceFormProps) { + ) : null} +
+

{t("settings.jevDescription")}

{t("settings.jevPrivacyNotice")}

- - setKeyDraft(event.target.value)} - /> - -

- {statusText} -

-

{t("settings.jevAddEnableNote")}

+ + {custom ? ( +
+ + onBindingChange?.({ + ...binding, + baseUrl: event.target.value, + })} + /> + {!validBaseUrl ? ( + + ) : null} + + + onBindingChange?.({ + ...binding, + modelId: event.target.value, + })} + /> + {!validModelId ? ( + + ) : null} + +
+ ) : null} + +
+ + updateKey(event.target.value)} + /> + + {custom ? ( + + onApiStyleChange?.(id as ProviderSetupApiStyle)} + options={CUSTOM_PROVIDER_SETUP_API_STYLES.map((style) => ({ + id: style, + label: t(PROVIDER_SETUP_API_STYLE_LABEL_KEYS[style]), + }))} + /> + + ) : null} +
+ +

{statusText}

+

{t("settings.jevClassifierOnlyNote")}

diff --git a/apps/desktop/src/components/settings/JevSettingsCard.tsx b/apps/desktop/src/components/settings/JevSettingsCard.tsx index 066e91b8b3..f88de25376 100644 --- a/apps/desktop/src/components/settings/JevSettingsCard.tsx +++ b/apps/desktop/src/components/settings/JevSettingsCard.tsx @@ -1,22 +1,30 @@ /** * Jev on the model configuration page (D625, settings IA). * - * The card is the state of the integration: whether a TypeSafe key is stored, - * whether the classifier is on for Agent mode, and the actions that change - * either. It is only there once Jev has been added — before that it would be a + * The card is the state of the integration: whether a System One key is stored, + * whether the classifier is on for Agent mode, and which endpoint/model it + * targets. It is only there once Jev has been added — before that it would be a * second place to paste a key, and adding stays where every other service is * added. The key itself is entered in the service dialog, which checks it - * against TypeSafe before anything is kept. + * against the configured endpoint before anything is kept. */ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { AppSettings } from "@pi-desktop/shared"; +import { + resolveJevClassifierBinding, + type AppSettings, +} from "@pi-desktop/shared"; import { api } from "../../lib/api"; import { useAppStore } from "../../stores/app-store"; import { Button, SettingsToggle } from "../ui"; import { SettingsCard, SettingsRow } from "../../features/settings/primitives"; import { removeJevService } from "./jev-config"; -import { persistJevEnabled, useJevKeyStatus } from "./jev-app-config"; +import { + disableAndClearJevConfiguration, + persistJevEnabled, + useJevKeyStatus, +} from "./jev-app-config"; +import { endpointLabel } from "./service-catalog"; type BusyAction = "toggle" | "remove" | null; @@ -39,6 +47,10 @@ export function JevSettingsCard({ const [busy, setBusy] = useState(null); const enabled = settings?.jevEnabled === true; const configured = keyStatus === "configured"; + const bindingResolution = resolveJevClassifierBinding(settings?.jevClassifier); + const binding = bindingResolution.status === "invalid" + ? null + : bindingResolution.binding; /* Jev is one of the services the user adds from "Add service", so an install without it has nothing to show here: a card would only be a second place to @@ -70,7 +82,7 @@ export function JevSettingsCard({ setBusy("remove"); try { await removeJevService({ - setEnabled: persistJevEnabled, + disableAndClear: disableAndClearJevConfiguration, deleteKey: () => api.deleteJevApiKey(), }); setKeyStatus("missing"); @@ -90,6 +102,9 @@ export function JevSettingsCard({ : keyStatus === "loading" ? t("settings.jevKeyStatusChecking") : t("settings.jevKeyMissing"); + const bindingDetail = binding + ? `${keyStatusText} · ${endpointLabel(binding.baseUrl)} · ${binding.modelId}` + : `${keyStatusText} · ${t("settings.jevBindingInvalid")}`; return (
) - ) : jevService ? jevView : formView} + ) : jevService || systemOneSetup ? jevView : formView} {advancedOpen && (named || custom) ? ( diff --git a/apps/desktop/src/components/settings/jev-app-config.ts b/apps/desktop/src/components/settings/jev-app-config.ts index db21a2a3bb..a70f96ce9d 100644 --- a/apps/desktop/src/components/settings/jev-app-config.ts +++ b/apps/desktop/src/components/settings/jev-app-config.ts @@ -1,43 +1,75 @@ -/** - * The app-wired half of Jev configuration: the real API calls, the settings - * write, and the key-status read both Jev surfaces show. - * - * Split from `jev-config.ts` so the ordering rules there stay testable without - * a renderer, an IPC bridge or a store. - */ +/** App wiring for the pure Jev configuration ordering rules. */ import { useEffect, useState } from "react"; -import type { AppSettings } from "@pi-desktop/shared"; +import { + isDefaultJevClassifierBinding, + normalizeJevClassifierBinding, + resolveJevClassifierBinding, + type AppSettings, + type JevClassifierBinding, +} from "@pi-desktop/shared"; import { api } from "../../lib/api"; import { useAppStore } from "../../stores/app-store"; import type { JevConfigDeps } from "./jev-config"; export type JevKeyStatus = "loading" | "configured" | "missing" | "unavailable"; -/** Jev is one field of the same AppSettings document, so it takes a full write. */ +async function persist(next: AppSettings, wire: AppSettings = next): Promise { + await api.setSettings(wire); + useAppStore.setState({ settings: next }); +} + +/** Jev is part of one AppSettings document, so every change is a full write. */ export async function persistJevEnabled(enabled: boolean): Promise { const current = useAppStore.getState().settings; if (!current) throw new Error("Settings are not ready"); - const next: AppSettings = { ...current, jevEnabled: enabled }; - await api.setSettings(next); - useAppStore.setState({ settings: next }); + if (enabled && resolveJevClassifierBinding(current.jevClassifier).status === "invalid") { + throw new Error("Jev classifier binding is invalid"); + } + await persist({ ...current, jevEnabled: enabled }); +} + +/** Save the normalized binding and enable Jev in one settings write. */ +export async function persistJevConfiguration( + binding: JevClassifierBinding, +): Promise { + const current = useAppStore.getState().settings; + if (!current) throw new Error("Settings are not ready"); + const normalized = normalizeJevClassifierBinding(binding); + if (!normalized) throw new Error("Jev classifier binding is invalid"); + + const next: AppSettings = { ...current, jevEnabled: true }; + delete next.jevClassifier; + if (!isDefaultJevClassifierBinding(normalized)) { + next.jevClassifier = normalized; + } + await persist(next, { + ...next, + // Explicit null clears a prior custom binding in Host's merge store. + ...(isDefaultJevClassifierBinding(normalized) ? { jevClassifier: null } : {}), + }); +} + +/** Disable the tool and remove a custom binding without changing the key yet. */ +export async function disableAndClearJevConfiguration(): Promise { + const current = useAppStore.getState().settings; + if (!current) throw new Error("Settings are not ready"); + const next: AppSettings = { ...current, jevEnabled: false }; + delete next.jevClassifier; + await persist(next, { ...next, jevClassifier: null }); } export function appJevConfigDeps(): JevConfigDeps { return { - probe: (key) => api.testJevApiKey(key), + probe: (key, binding) => api.testJevApiKey({ apiKey: key, binding }), storeKey: (key) => api.setJevApiKey(key), deleteKey: () => api.deleteJevApiKey(), - setEnabled: persistJevEnabled, + configure: persistJevConfiguration, + disableAndClear: disableAndClearJevConfiguration, }; } -/** - * Whether a TypeSafe key is stored. The renderer only ever learns whether one - * exists, and a Host that cannot answer reads as `unavailable` — never as - * "no key", which would describe a configured install as unconfigured. - */ +/** The renderer learns only whether a credential exists, never its value. */ export function useJevKeyStatus( - /** Bump to re-read: the service dialog reports a key it just stored this way. */ revision = 0, ): readonly [JevKeyStatus, (next: JevKeyStatus) => void] { const [status, setStatus] = useState("loading"); diff --git a/apps/desktop/src/components/settings/jev-config.ts b/apps/desktop/src/components/settings/jev-config.ts index 927c77cbb4..7eafc11a83 100644 --- a/apps/desktop/src/components/settings/jev-config.ts +++ b/apps/desktop/src/components/settings/jev-config.ts @@ -1,27 +1,28 @@ /** - * The order Jev's credential and its switch move in. + * Ordering guarantees for the optional Jev classifier configuration. * - * Adding Jev means "a key that works is stored, and the classifier is on", in - * that order: the key is checked against TypeSafe first, nothing is written - * when the check fails, and only a key that answered becomes `jevEnabled`. So - * an enabled Jev always has a key the Agent can actually spend. - * - * Removing Jev takes the switch down before the key is deleted, so no state in - * between can leave an enabled classifier without a key. Closing the dialog - * abandons an in-flight check the same way: cancel means nothing was kept. - * - * This lives outside the components because the settings card and the service - * dialog both drive it, and because the order is the part worth testing. It - * imports nothing at runtime: the caller supplies the effects. + * The remote endpoint is checked before either credential or settings move. + * A successful add stores the key first, then atomically writes the non-secret + * endpoint/model and enables the tool. Removal disables and clears the binding + * before deleting the credential, so an enabled classifier never lacks a key. */ -import type { JevKeyCheckResult } from "@pi-desktop/shared"; +import type { + JevClassifierBinding, + JevKeyCheckResult, +} from "@pi-desktop/shared"; export type JevConfigDeps = { - /** Ask TypeSafe whether the key works; never stores anything. */ - probe: (key: string) => Promise; + /** Ask the configured System One endpoint whether the key works. */ + probe: ( + key: string, + binding: JevClassifierBinding, + ) => Promise; storeKey: (key: string) => Promise; deleteKey: () => Promise; - setEnabled: (enabled: boolean) => Promise; + /** Persist this non-secret binding and enable Jev in one settings write. */ + configure: (binding: JevClassifierBinding) => Promise; + /** Disable Jev and remove any explicit binding in one settings write. */ + disableAndClear: () => Promise; }; export type JevAddOutcome = @@ -30,35 +31,55 @@ export type JevAddOutcome = | { ok: false; reason: "cancelled" } | { ok: false; reason: "check-failed"; status?: number; message?: string }; -/** - * Store `apiKey` and turn Jev on, or leave everything untouched. - * - * `signal` abandons the whole action: a dialog the user closed while the check - * was still in flight must not come back later with a stored credential and an - * enabled classifier. The check is the long part, so it is the only step that - * needs the guard — everything after it has already been decided. - */ +/** Check, store, then configure Jev, or leave settings untouched. */ export async function addJevService( deps: JevConfigDeps, apiKey: string, + binding: JevClassifierBinding, signal?: AbortSignal, ): Promise { const key = apiKey.trim(); if (!key) return { ok: false, reason: "missing-key" }; - const check = await deps.probe(key); + const check = await deps.probe(key, binding); if (signal?.aborted) return { ok: false, reason: "cancelled" }; if (!check.ok) { - return { ok: false, reason: "check-failed", status: check.status, message: check.message }; + return { + ok: false, + reason: "check-failed", + status: check.status, + message: check.message, + }; } await deps.storeKey(key); - await deps.setEnabled(true); + try { + await deps.configure(binding); + } catch (error) { + const cleanupErrors: unknown[] = []; + try { + await deps.disableAndClear(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + try { + await deps.deleteKey(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + "Jev configuration failed and could not be cleaned up completely", + ); + } + throw error; + } return { ok: true }; } -/** Turn Jev off and drop the key; disabled first, so neither step is orphaned. */ +/** Disable/clear Jev before deleting its key. */ export async function removeJevService( - deps: Pick, + deps: Pick, ): Promise { - await deps.setEnabled(false); + await deps.disableAndClear(); await deps.deleteKey(); } diff --git a/apps/desktop/src/components/settings/provider-api-style.ts b/apps/desktop/src/components/settings/provider-api-style.ts index cf81345bdf..089600ab53 100644 --- a/apps/desktop/src/components/settings/provider-api-style.ts +++ b/apps/desktop/src/components/settings/provider-api-style.ts @@ -1,6 +1,7 @@ import { API_STYLES, OPENCODE_GO_API_STYLE, + JEV_SYSTEM_ONE_API_STYLE, matchNamedPreset, type CatalogApiStyle, type ProviderPublic, @@ -15,6 +16,16 @@ export const CUSTOM_PROVIDER_API_STYLES = API_STYLES.filter( (style) => !isAccountOnlyApiStyle(style) && style !== OPENCODE_GO_API_STYLE, ); +/** Setup-only classifier format; it never joins the chat transport catalog. */ +export type ProviderSetupApiStyle = + | CatalogApiStyle + | typeof JEV_SYSTEM_ONE_API_STYLE; + +export const CUSTOM_PROVIDER_SETUP_API_STYLES: readonly ProviderSetupApiStyle[] = [ + ...CUSTOM_PROVIDER_API_STYLES, + JEV_SYSTEM_ONE_API_STYLE, +]; + /** Localized names for every transport in the catalog. */ export const API_STYLE_LABEL_KEYS: Record = { chat_completions: "settings.apiStyleChatCompletions", @@ -26,6 +37,14 @@ export const API_STYLE_LABEL_KEYS: Record = { opencode_go: "settings.apiStyleOpenCodeGo", }; +export const PROVIDER_SETUP_API_STYLE_LABEL_KEYS: Record< + ProviderSetupApiStyle, + string +> = { + ...API_STYLE_LABEL_KEYS, + [JEV_SYSTEM_ONE_API_STYLE]: "settings.apiStyleSystemOne", +}; + export function needsCustomApiStyleChoice( style: CatalogApiStyle, savedStyle?: string, diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 7e08a32701..c2c3fd2ec7 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -32,6 +32,7 @@ import type { AgentInstructionFile, AppSettings, JevKeyCheckResult, + JevKeyCheckInput, CommandShellCatalog, AppVersionInfo, BrowserAction, @@ -161,6 +162,8 @@ import { normalizeMode, normalizeNetworkProxy, normalizeNetworkPolicy, + normalizeJevClassifierBinding, + resolveJevClassifierBinding, resolveFontScale, normalizeChatContentMaxWidth, validateNetworkPolicy, @@ -418,12 +421,19 @@ export type SessionHistoryReadOptions = { }; export function normalizeSettings(settings: AppSettings): AppSettings { + const jev = resolveJevClassifierBinding( + (settings as { jevClassifier?: unknown }).jevClassifier, + ); return { ...settings, defaultMode: normalizeMode((settings as { defaultMode?: unknown }).defaultMode), infiniteProviderRetry: (settings as { infiniteProviderRetry?: unknown }).infiniteProviderRetry === true, - jevEnabled: (settings as { jevEnabled?: unknown }).jevEnabled === true, + jevEnabled: + (settings as { jevEnabled?: unknown }).jevEnabled === true && + jev.status !== "invalid", + ...(jev.status === "configured" ? { jevClassifier: jev.binding } : {}), + ...(jev.status === "invalid" ? { jevClassifier: null } : {}), defaultCommandShell: isCommandShellId( (settings as { defaultCommandShell?: unknown }).defaultCommandShell, ) @@ -468,6 +478,7 @@ export function validateSettingsWrite(settings: AppSettings): AppSettings { chatContentMaxWidth?: unknown; infiniteProviderRetry?: unknown; jevEnabled?: unknown; + jevClassifier?: unknown; smoothStreaming?: unknown; sidebarRunningIndicators?: unknown; sidebarRunningBadge?: unknown; @@ -527,6 +538,17 @@ export function validateSettingsWrite(settings: AppSettings): AppSettings { errorCode: "INVALID_PARAMS", }); } + if (Object.prototype.hasOwnProperty.call(value, "jevClassifier")) { + if (value.jevClassifier !== null) { + const binding = normalizeJevClassifierBinding(value.jevClassifier); + if (!binding) { + throw Object.assign(new Error("jevClassifier is invalid"), { + errorCode: "INVALID_PARAMS", + }); + } + value.jevClassifier = binding; + } + } if ( Object.prototype.hasOwnProperty.call(value, "smoothStreaming") && typeof value.smoothStreaming !== "boolean" @@ -783,12 +805,9 @@ export const api = { deleteJevApiKey: () => invoke(IPC.invoke.secretsDelete, JEV_API_KEY_SECRET_REF), hasJevApiKey: () => invoke<{ has: boolean }>(IPC.invoke.secretsHas, JEV_API_KEY_SECRET_REF).then((result) => result.has), - /** - * Ask TypeSafe whether this key works, without storing anything. Jev keeps - * a key only after this answered it; the check text is TypeSafe's own. - */ - testJevApiKey: (value: string) => - invoke(IPC.invoke.jevTest, value), + /** Check a System One key at the exact endpoint/model without storing it. */ + testJevApiKey: (input: JevKeyCheckInput | string) => + invoke(IPC.invoke.jevTest, input), backgroundTasksList: () => invoke(IPC.invoke.backgroundTasksList), backgroundTasksOutput: (id: string) => diff --git a/apps/desktop/test/jev-api-key-check.test.mjs b/apps/desktop/test/jev-api-key-check.test.mjs index ba462047b0..7916d33903 100644 --- a/apps/desktop/test/jev-api-key-check.test.mjs +++ b/apps/desktop/test/jev-api-key-check.test.mjs @@ -1,11 +1,9 @@ /** - * Contract test for the TypeSafe key check behind Jev (settings IA, D625). + * Contract tests for the non-persisting Jev System One credential check. * - * Jev keeps a key only when TypeSafe answered it, so this probe is the gate: - * it must ask the same address and classifier the Agent's `JevClassify` tool - * uses, report a refusal with its status, never leak the key into its own - * message, and answer a hung or unreachable service with a sentence instead - * of a rejection. + * The probe must ask the same normalized endpoint and exact classifier id the + * Agent's `JevClassify` tool uses, report refusals with status, never leak the + * key, refuse cross-origin credential redirects, and bound silent services. */ import assert from "node:assert/strict"; import test from "node:test"; @@ -56,6 +54,20 @@ test("the check asks the System One endpoint the classifier tool uses", async () assert.deepEqual(Object.keys(question.criteria), ["true", "false"]); }); +test("legacy empty options still use the official binding", async () => { + const originalFetch = globalThis.fetch; + const { calls, fetch } = recordingFetch(jsonResponse({ ok: true, status: 200 })); + globalThis.fetch = fetch; + try { + const result = await probeJevApiKey(KEY, {}); + assert.deepEqual(result, { ok: true, status: 200 }); + assert.equal(calls[0].url, TYPESAFE_SYSTEM_ONE_URL); + assert.equal(JSON.parse(calls[0].init.body).model, TYPESAFE_JEV_MODEL_ID); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("a trimmed key is what travels, and a blank key sends nothing", async () => { const { calls, fetch } = recordingFetch(jsonResponse({ ok: true, status: 200 })); await probeJevApiKey(` ${KEY} `, { fetchImpl: fetch }); @@ -80,10 +92,58 @@ test("a refused key reports TypeSafe's status without repeating the key", async assert.match(result.message, /\[redacted\]/); }); +test("a long key is redacted before provider detail is truncated", async () => { + const longKey = `fixture-${"k".repeat(400)}`; + const { fetch } = recordingFetch( + jsonResponse({ ok: false, status: 401, body: `invalid api key ${longKey}` }), + ); + const result = await probeJevApiKey(longKey, { fetchImpl: fetch }); + + assert.equal(result.ok, false); + assert.match(result.message, /\[redacted\]/); + assert.ok(!result.message.includes(longKey.slice(0, 80))); +}); + test("a refusal without a body still names the status", async () => { const { fetch } = recordingFetch(jsonResponse({ ok: false, status: 403 })); const result = await probeJevApiKey(KEY, { fetchImpl: fetch }); - assert.deepEqual(result, { ok: false, status: 403, message: "TypeSafe returned 403" }); + assert.deepEqual(result, { ok: false, status: 403, message: "System One returned 403" }); +}); + +test("a custom root and full operation use one /systemone with the exact model id", async () => { + for (const baseUrl of [ + "https://openrouter.test/api/v1", + "https://openrouter.test/api/v1/systemone/", + ]) { + const { calls, fetch } = recordingFetch(jsonResponse({ ok: true, status: 200 })); + const asserted = []; + const result = await probeJevApiKey(KEY, { + baseUrl, + modelId: "~typesafe/jev-latest", + }, { + fetchImpl: fetch, + assertEndpoint: async (url) => asserted.push(url), + }); + assert.equal(result.ok, true); + assert.equal(calls[0].url, "https://openrouter.test/api/v1/systemone"); + assert.deepEqual(asserted, ["https://openrouter.test/api/v1/systemone"]); + assert.equal(JSON.parse(calls[0].init.body).model, "~typesafe/jev-latest"); + } +}); + +test("a credential-bearing redirect cannot leave the configured origin", async () => { + const { calls, fetch } = recordingFetch(new Response(null, { + status: 307, + headers: { location: "https://attacker.test/systemone" }, + })); + const result = await probeJevApiKey(KEY, { + baseUrl: "https://openrouter.test/api/v1", + modelId: "~typesafe/jev-latest", + }, { fetchImpl: fetch }); + assert.equal(result.ok, false); + assert.match(result.message, /left the configured origin/); + assert.equal(calls.length, 1); + assert.ok(!result.message.includes(KEY)); }); test("an unreachable service answers with a message, not a rejection", async () => { diff --git a/apps/desktop/test/jev-config.test.mjs b/apps/desktop/test/jev-config.test.mjs index 8b991ab1b3..aa8cbfd949 100644 --- a/apps/desktop/test/jev-config.test.mjs +++ b/apps/desktop/test/jev-config.test.mjs @@ -1,26 +1,21 @@ -/** - * The order Jev's credential and switch move in. - * - * Adding Jev stores a key only after TypeSafe answered it and then enables the - * classifier; removing Jev disables it before the key is deleted. Both orders - * are the contract this test pins, because either one reversed leaves a state - * the Agent cannot run in: an enabled classifier without a key, or a key with - * nothing to spend it on. - */ +/** Jev's check/store/configure ordering, independent of React and IPC. */ import assert from "node:assert/strict"; import test from "node:test"; import { addJevService, removeJevService } from "../src/components/settings/jev-config.ts"; -const KEY = "typesafe-fixture-key"; +const KEY = "system-one-fixture-key"; +const BINDING = { + baseUrl: "https://openrouter.test/api/v1", + modelId: "~typesafe/jev-latest", +}; -/** Records what the caller asked for, in the order it happened. */ function deps({ check = { ok: true, status: 200 }, failStore = false } = {}) { const log = []; return { log, config: { - probe: async (key) => { - log.push(["probe", key]); + probe: async (key, binding) => { + log.push(["probe", key, binding]); return check; }, storeKey: async (key) => { @@ -30,74 +25,99 @@ function deps({ check = { ok: true, status: 200 }, failStore = false } = {}) { deleteKey: async () => { log.push(["delete"]); }, - setEnabled: async (enabled) => { - log.push(["enabled", enabled]); + configure: async (binding) => { + log.push(["configure", binding]); + }, + disableAndClear: async () => { + log.push(["disable-and-clear"]); }, }, }; } -test("a key that answered is stored, then Jev is enabled", async () => { +test("a key that answered is stored, then its binding is enabled", async () => { const { log, config } = deps(); - const outcome = await addJevService(config, ` ${KEY} `); + const outcome = await addJevService(config, ` ${KEY} `, BINDING); assert.deepEqual(outcome, { ok: true }); assert.deepEqual(log, [ - ["probe", KEY], + ["probe", KEY, BINDING], ["store", KEY], - ["enabled", true], + ["configure", BINDING], ]); }); test("a blank key is never probed and never stored", async () => { const { log, config } = deps(); - assert.deepEqual(await addJevService(config, " "), { ok: false, reason: "missing-key" }); + assert.deepEqual(await addJevService(config, " ", BINDING), { + ok: false, + reason: "missing-key", + }); assert.deepEqual(log, []); }); test("a check the user closed the dialog on is cancelled, not stored", async () => { const { log, config } = deps(); const controller = new AbortController(); - // The dialog closes while TypeSafe is still thinking, then answers ok. - config.probe = async (key) => { - log.push(["probe", key]); + config.probe = async (key, binding) => { + log.push(["probe", key, binding]); controller.abort(); return { ok: true, status: 200 }; }; - const outcome = await addJevService(config, KEY, controller.signal); + const outcome = await addJevService(config, KEY, BINDING, controller.signal); assert.deepEqual(outcome, { ok: false, reason: "cancelled" }); - assert.deepEqual(log, [["probe", KEY]]); + assert.deepEqual(log, [["probe", KEY, BINDING]]); }); -test("a key TypeSafe refused is reported and nothing is written", async () => { +test("a refused key is reported and nothing is written", async () => { const { log, config } = deps({ - check: { ok: false, status: 401, message: "TypeSafe returned 401: invalid key" }, + check: { ok: false, status: 401, message: "System One returned 401" }, }); - const outcome = await addJevService(config, KEY); + const outcome = await addJevService(config, KEY, BINDING); assert.deepEqual(outcome, { ok: false, reason: "check-failed", status: 401, - message: "TypeSafe returned 401: invalid key", + message: "System One returned 401", }); - // Only the check happened: a refused key must not reach the secret store, - // and must not switch the classifier on. - assert.deepEqual(log, [["probe", KEY]]); + assert.deepEqual(log, [["probe", KEY, BINDING]]); }); -test("a store failure surfaces and leaves Jev disabled", async () => { +test("a store failure surfaces before settings can enable the classifier", async () => { const { log, config } = deps({ failStore: true }); - await assert.rejects(() => addJevService(config, KEY), /secret store unavailable/); + await assert.rejects( + () => addJevService(config, KEY, BINDING), + /secret store unavailable/, + ); + assert.deepEqual(log, [ + ["probe", KEY, BINDING], + ["store", KEY], + ]); +}); + +test("a settings failure disables, clears, and removes the newly stored key", async () => { + const { log, config } = deps(); + config.configure = async (binding) => { + log.push(["configure", binding]); + throw new Error("settings unavailable"); + }; + await assert.rejects( + () => addJevService(config, KEY, BINDING), + /settings unavailable/, + ); assert.deepEqual(log, [ - ["probe", KEY], + ["probe", KEY, BINDING], ["store", KEY], + ["configure", BINDING], + ["disable-and-clear"], + ["delete"], ]); }); -test("removing Jev disables it before the key is deleted", async () => { +test("removing Jev disables and clears the binding before deleting the key", async () => { const { log, config } = deps(); await removeJevService(config); - assert.deepEqual(log, [["enabled", false], ["delete"]]); + assert.deepEqual(log, [["disable-and-clear"], ["delete"]]); }); diff --git a/apps/desktop/test/jev-service-setup-user-path.test.mjs b/apps/desktop/test/jev-service-setup-user-path.test.mjs index 60b278bbeb..c431d68ab5 100644 --- a/apps/desktop/test/jev-service-setup-user-path.test.mjs +++ b/apps/desktop/test/jev-service-setup-user-path.test.mjs @@ -2,10 +2,9 @@ * Isolated Electron user path for adding Jev as a service (D625). * * Jev is offered where a service is added, not where an existing row is - * edited, and adding it is one action with two steps: check the TypeSafe key, - * then store it and turn the classifier on. The check comes first and nothing - * is written when it fails — otherwise a saved key would be a key the Agent - * cannot spend, and "enabled" would mean nothing. + * edited, and adding it is one action with two steps: check the configured + * System One key, then store it and enable the classifier. The check comes + * first and nothing is written when it fails. */ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; @@ -40,6 +39,8 @@ let pickedService = null; let configured = false; let savedSettings = { defaultMode: "agent", + defaultProviderId: "chat-provider", + defaultModelId: "chat-model", theme: "light", language: "en", developerMode: false, @@ -188,14 +189,14 @@ window.jevServiceSetupProbe = async () => { jevCheck = { ok: true, status: 200 }; mount(React.createElement(JevSurface, { initiallyOpen: true })); const cardAbsentBefore = !document.querySelector('[role="switch"][aria-label="Enable Jev for Agent"]'); - await waitFor(() => !!document.querySelector('input[aria-label="TypeSafe API key"]'), "the Jev form did not open"); + await waitFor(() => !!document.querySelector('input[aria-label="System One API key"]'), "the Jev form did not open"); // The dialog is portaled onto documentElement, so the whole page is read. const privacyNoticeShown = await waitFor( - () => document.documentElement.innerText.includes("sent directly to TypeSafe"), + () => document.documentElement.innerText.includes("configured System One endpoint"), "the Jev form must carry the privacy notice", ); const noModelPanes = !document.documentElement.innerText.includes("Models from this service"); - setValue(document.querySelector('input[aria-label="TypeSafe API key"]'), "jev-ui-fixture-key"); + setValue(document.querySelector('input[aria-label="System One API key"]'), "jev-ui-fixture-key"); await settle(); flushSync(() => button("Check and save").click()); await waitFor( @@ -204,26 +205,29 @@ window.jevServiceSetupProbe = async () => { ); const cardSwitch = document.querySelector('[role="switch"][aria-label="Enable Jev for Agent"]'); const switchOn = cardSwitch?.getAttribute("aria-checked") === "true"; - const dialogClosed = !document.querySelector('input[aria-label="TypeSafe API key"]'); - // The check, the store and the switch, in that order. + const dialogClosed = !document.querySelector('input[aria-label="System One API key"]'); + // The check, the store and the settings write, in that order. const flow = apiCalls.filter((call) => call.channel !== "pi-desktop/secrets/has"); const checkedFirst = flow[0]?.channel === "pi-desktop/jev/test"; - const checkedTheTypedKey = checkedFirst && flow[0].input === "jev-ui-fixture-key"; + const checkedTheTypedKey = checkedFirst + && flow[0].input.apiKey === "jev-ui-fixture-key" + && flow[0].input.binding.baseUrl === "https://api.typesafe.ai/v1" + && flow[0].input.binding.modelId === "jev-latest"; const storedAfterChecking = flow[1]?.channel === "pi-desktop/secrets/set" && flow[1].input.secretRef === JEV_API_KEY_SECRET_REF && flow[1].input.value === "jev-ui-fixture-key"; const enabledAfterStoring = flow[2]?.channel === "pi-desktop/settings/set" && flow[2].input.jevEnabled === true; - // 4) A key TypeSafe refuses is never stored and never enabled. + // 4) A key the endpoint refuses is never stored and never enabled. apiCalls.length = 0; savedKey = undefined; configured = false; useAppStore.setState({ toasts: [] }); - jevCheck = { ok: false, status: 401, message: "TypeSafe returned 401: invalid api key" }; + jevCheck = { ok: false, status: 401, message: "System One returned 401: invalid api key" }; mount(jevDialog("refused")); - await waitFor(() => !!document.querySelector('input[aria-label="TypeSafe API key"]'), "the Jev form did not reopen"); - setValue(document.querySelector('input[aria-label="TypeSafe API key"]'), "jev-rejected-key"); + await waitFor(() => !!document.querySelector('input[aria-label="System One API key"]'), "the Jev form did not reopen"); + setValue(document.querySelector('input[aria-label="System One API key"]'), "jev-rejected-key"); await settle(); flushSync(() => button("Check and save").click()); await waitFor(() => apiCalls.some((call) => call.channel === "pi-desktop/jev/test"), "the key was never checked"); @@ -233,7 +237,7 @@ window.jevServiceSetupProbe = async () => { nothingStored: savedKey === undefined && !apiCalls.some((call) => call.channel === "pi-desktop/secrets/set"), nothingEnabled: !apiCalls.some((call) => call.channel === "pi-desktop/settings/set"), - stillOpen: !!document.querySelector('input[aria-label="TypeSafe API key"]'), + stillOpen: !!document.querySelector('input[aria-label="System One API key"]'), notConfigured: configured === false, reported: useAppStore.getState().toasts.some( (item) => item.variant === "error" && item.message.includes("401"), @@ -248,13 +252,13 @@ window.jevServiceSetupProbe = async () => { useAppStore.setState({ toasts: [] }); pendingCheck = { resolve: null, result: { ok: true, status: 200 } }; mount(jevDialog("abandoned")); - await waitFor(() => !!document.querySelector('input[aria-label="TypeSafe API key"]'), "the Jev form did not open"); - setValue(document.querySelector('input[aria-label="TypeSafe API key"]'), "jev-abandoned-key"); + await waitFor(() => !!document.querySelector('input[aria-label="System One API key"]'), "the Jev form did not open"); + setValue(document.querySelector('input[aria-label="System One API key"]'), "jev-abandoned-key"); await settle(); flushSync(() => button("Check and save").click()); await waitFor(() => apiCalls.some((call) => call.channel === "pi-desktop/jev/test"), "the check never started"); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); - await waitFor(() => !document.querySelector('input[aria-label="TypeSafe API key"]'), "Escape did not close the Jev form"); + await waitFor(() => !document.querySelector('input[aria-label="System One API key"]'), "Escape did not close the Jev form"); flushSync(() => pendingCheck.resolve()); await settle(); await settle(); @@ -266,6 +270,60 @@ window.jevServiceSetupProbe = async () => { }; pendingCheck = null; + // 6) Custom Endpoint can switch to classifier-only System One without + // creating a chat provider or changing the current chat default. + apiCalls.length = 0; + savedKey = undefined; + configured = false; + jevCheck = { ok: true, status: 200 }; + let customConfigured = false; + mount(React.createElement(ProviderSetupDialog, { + provider: null, + initialService: "custom", + onClose: () => mount(null), + onSaved: () => { throw new Error("System One must not create a chat provider"); }, + onJevConfigured: () => { + customConfigured = true; + mount(React.createElement(JevSurface)); + }, + })); + await waitFor(() => !!document.querySelector('button[aria-label="API format"]'), "custom API format did not render"); + flushSync(() => document.querySelector('button[aria-label="API format"]').click()); + await waitFor(() => button("System One (classifier)"), "System One format was not offered"); + flushSync(() => button("System One (classifier)").click()); + const customBase = await waitFor( + () => document.querySelector('input[placeholder="https://openrouter.ai/api/v1"]'), + "System One Base URL did not render", + ); + const customModel = document.querySelector('input[placeholder="~typesafe/jev-latest"]'); + const customKey = document.querySelector('input[aria-label="System One API key"]'); + setValue(customBase, "https://openrouter.test/api/v1/systemone/"); + setValue(customModel, "~typesafe/jev-latest"); + setValue(customKey, "openrouter-fixture-key"); + await settle(); + const classifierOnlyCopy = document.documentElement.innerText.includes( + "will not appear in chat, default-model, or subagent model lists", + ); + const customNoModelPanes = !document.documentElement.innerText.includes("Models from this service"); + flushSync(() => button("Check and save").click()); + await waitFor(() => customConfigured, "custom System One configuration did not finish"); + const customCardShowsBinding = await waitFor( + () => document.body.innerText.includes("openrouter.test") + && document.body.innerText.includes("~typesafe/jev-latest"), + "the Jev card did not show the custom endpoint and model", + ); + const customFlow = apiCalls.filter((call) => call.channel !== "pi-desktop/secrets/has"); + const customCheck = customFlow.find((call) => call.channel === "pi-desktop/jev/test"); + const customSettings = customFlow.find((call) => call.channel === "pi-desktop/settings/set"); + const customBindingSaved = customCheck?.input.apiKey === "openrouter-fixture-key" + && customCheck.input.binding.baseUrl === "https://openrouter.test/api/v1" + && customCheck.input.binding.modelId === "~typesafe/jev-latest" + && customSettings?.input.jevClassifier.baseUrl === "https://openrouter.test/api/v1" + && customSettings.input.jevClassifier.modelId === "~typesafe/jev-latest"; + const noChatProviderCreated = !apiCalls.some((call) => call.channel === "pi-desktop/providers/create"); + const chatDefaultPreserved = customSettings?.input.defaultProviderId === "chat-provider" + && customSettings.input.defaultModelId === "chat-model"; + return { offersClassifiers, pickedJev, @@ -288,6 +346,12 @@ window.jevServiceSetupProbe = async () => { abandonedNothingStored: abandoned.nothingStored, abandonedNothingEnabled: abandoned.nothingEnabled, abandonedSilent: abandoned.noSuccessToast, + customBindingSaved, + customNoModelPanes, + classifierOnlyCopy, + noChatProviderCreated, + chatDefaultPreserved, + customCardShowsBinding, }; }; `; @@ -394,6 +458,12 @@ app.whenReady().then(async () => { abandonedNothingStored: true, abandonedNothingEnabled: true, abandonedSilent: true, + customBindingSaved: true, + customNoModelPanes: true, + classifierOnlyCopy: true, + noChatProviderCreated: true, + chatDefaultPreserved: true, + customCardShowsBinding: true, }); } finally { await rm(temp, { recursive: true, force: true }); diff --git a/apps/desktop/test/jev-session-launch.test.mjs b/apps/desktop/test/jev-session-launch.test.mjs index 55d96fd715..9d1dab6341 100644 --- a/apps/desktop/test/jev-session-launch.test.mjs +++ b/apps/desktop/test/jev-session-launch.test.mjs @@ -9,7 +9,16 @@ import { repositoryRoot } from "../../../scripts/e2e/boot.mjs"; const root = repositoryRoot(); const { build } = createRequire(join(root, "packages/agent-runtime/package.json"))("esbuild"); -test("session launch resolves Jev credentials only for opted-in Agent mode", async () => { +const OFFICIAL = { + baseUrl: "https://api.typesafe.ai/v1", + modelId: "jev-latest", +}; +const CUSTOM = { + baseUrl: "https://openrouter.test/api/v1", + modelId: "~typesafe/jev-latest", +}; + +test("session launch resolves the configured Jev classifier only for opted-in Agent mode", async () => { const temp = await mkdtemp(join(root, "apps/desktop/.jev-launch-test-")); const priorHome = process.env.HOME; process.env.HOME = temp; @@ -36,6 +45,7 @@ test("session launch resolves Jev credentials only for opted-in Agent mode", asy models: [], }; const keyReads = []; + const endpointChecks = []; const logs = []; let failJevSecretRead = false; const host = { @@ -93,9 +103,10 @@ test("session launch resolves Jev credentials only for opted-in Agent mode", asy bindingForModel: () => undefined, effectiveSubagentModelConfig: () => ({ modelConfig: undefined, capabilities: {} }), normalizeThinkingLevel: () => "off", + assertJevEndpoint: async (url) => endpointChecks.push(url), }; const launchRuntime = createSessionLaunchRuntime(dependencies); - const launch = async (mode, jevEnabled) => + const launch = async (mode, jevEnabled, jevClassifier) => launchRuntime.resolveAgentRuntimeLaunch( "session-fixture", { providerId: provider.id, modelId: provider.defaultModelId }, @@ -104,31 +115,60 @@ test("session launch resolves Jev credentials only for opted-in Agent mode", asy defaultModelId: provider.defaultModelId, defaultMode: mode, jevEnabled, + ...(jevClassifier !== undefined ? { jevClassifier } : {}), }, ); const enabledAgent = await launch("agent", true); - assert.equal(enabledAgent.sidecarParams.jevApiKey, "jev-launch-fixture-key"); + assert.deepEqual(enabledAgent.sidecarParams.jev, { + ...OFFICIAL, + apiKey: "jev-launch-fixture-key", + }); assert.equal(enabledAgent.sidecarParams.provider.apiKey, ""); assert.deepEqual(keyReads, [{ secretRef: "secret:app:typesafe-jev" }]); + assert.deepEqual(endpointChecks, ["https://api.typesafe.ai/v1/systemone"]); + + keyReads.length = 0; + endpointChecks.length = 0; + const custom = await launch("agent", true, CUSTOM); + assert.deepEqual(custom.sidecarParams.jev, { + ...CUSTOM, + apiKey: "jev-launch-fixture-key", + }); + assert.deepEqual(endpointChecks, ["https://openrouter.test/api/v1/systemone"]); keyReads.length = 0; - const disabledAgent = await launch("agent", false); - assert.equal("jevApiKey" in disabledAgent.sidecarParams, false); + endpointChecks.length = 0; + const disabledAgent = await launch("agent", false, CUSTOM); + assert.equal("jev" in disabledAgent.sidecarParams, false); + assert.deepEqual(keyReads, []); + assert.deepEqual(endpointChecks, []); + + const plan = await launch("plan", true, CUSTOM); + assert.equal("jev" in plan.sidecarParams, false); assert.deepEqual(keyReads, []); - const plan = await launch("plan", true); - assert.equal("jevApiKey" in plan.sidecarParams, false); + logs.length = 0; + const invalid = await launch("agent", true, { baseUrl: "file:///tmp", modelId: "jev" }); + assert.equal("jev" in invalid.sidecarParams, false); assert.deepEqual(keyReads, []); + assert.deepEqual(endpointChecks, []); + assert.deepEqual(logs, [[ + "session", + "warn", + "Jev classifier binding is invalid", + { sessionId: "session-fixture" }, + ]]); + logs.length = 0; failJevSecretRead = true; - const unavailable = await launch("agent", true); - assert.equal("jevApiKey" in unavailable.sidecarParams, false); + const unavailable = await launch("agent", true, CUSTOM); + assert.equal("jev" in unavailable.sidecarParams, false); assert.equal(JSON.stringify(logs).includes("private fixture detail"), false); assert.deepEqual(logs, [[ "session", "warn", - "Jev API key could not be loaded", + "Jev configuration could not be loaded", { sessionId: "session-fixture" }, ]]); } finally { diff --git a/apps/desktop/test/jev-settings-user-path.test.mjs b/apps/desktop/test/jev-settings-user-path.test.mjs index 85eb48b4ab..a465792c63 100644 --- a/apps/desktop/test/jev-settings-user-path.test.mjs +++ b/apps/desktop/test/jev-settings-user-path.test.mjs @@ -3,10 +3,11 @@ * * What the card is now: the state of the integration, shown only once Jev has * been added. An install without it has nothing to show here, because adding - * happens where every other service is added. It reads whether a TypeSafe key + * happens where every other service is added. It reads whether a System One key * is stored, lets the switch move only when one is, and sends the user to the * service dialog to add or replace the key; it never stores a key itself. - * Removing the key takes the switch down first, and then takes the card away. + * Removing the key takes the switch down first, clears the binding, and then + * takes the card away. * * The key is written by the service dialog (see the Jev service setup test); * this fixture stands in for that by storing one in the fake Host and @@ -119,9 +120,9 @@ window.jevSettingsProbe = async () => { () => document.body.innerText.includes("API key saved securely"), "the card did not appear for a stored key", ); - const inlinePrivacyNoticeHidden = !document.body.innerText.includes("sent directly to TypeSafe"); + const inlinePrivacyNoticeHidden = !document.body.innerText.includes("configured System One endpoint"); const privacyNoticeAvailableFromHelp = [...document.querySelectorAll("button")].some( - (candidate) => candidate.getAttribute("aria-label")?.includes("sent directly to TypeSafe"), + (candidate) => candidate.getAttribute("aria-label")?.includes("configured System One endpoint"), ); const toggle = () => document.querySelector('[role="switch"][aria-label="Enable Jev for Agent"]'); if (!toggle()) throw new Error("the Jev switch is missing"); diff --git a/apps/desktop/test/provider-api-style.test.mjs b/apps/desktop/test/provider-api-style.test.mjs index bed759db9a..ca56aeb469 100644 --- a/apps/desktop/test/provider-api-style.test.mjs +++ b/apps/desktop/test/provider-api-style.test.mjs @@ -2,9 +2,13 @@ import assert from "node:assert/strict"; import { register } from "node:module"; import test from "node:test"; register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); -const { API_STYLES } = await import("@pi-desktop/shared"); -const { CUSTOM_PROVIDER_API_STYLES, needsCustomApiStyleChoice, providerSetupPreset } = - await import("../src/components/settings/provider-api-style.ts"); +const { API_STYLES, JEV_SYSTEM_ONE_API_STYLE } = await import("@pi-desktop/shared"); +const { + CUSTOM_PROVIDER_API_STYLES, + CUSTOM_PROVIDER_SETUP_API_STYLES, + needsCustomApiStyleChoice, + providerSetupPreset, +} = await import("../src/components/settings/provider-api-style.ts"); const { copyProviderConfiguration } = await import("../src/components/settings/provider-copy.ts"); const source = (apiStyle) => ({ @@ -14,9 +18,12 @@ const source = (apiStyle) => ({ thinkingLevels: ["off"], defaultThinkingLevel: "off" }], }); -test("custom creation offers general APIs without removing account or named transports", () => { +test("custom setup adds System One without making it a chat transport", () => { assert.deepEqual(CUSTOM_PROVIDER_API_STYLES, ["chat_completions", "responses", "anthropic_messages", "google_generative_ai"]); + assert.deepEqual(CUSTOM_PROVIDER_SETUP_API_STYLES, + [...CUSTOM_PROVIDER_API_STYLES, JEV_SYSTEM_ONE_API_STYLE]); + assert.equal(API_STYLES.includes(JEV_SYSTEM_ONE_API_STYLE), false); for (const style of ["openai_codex_responses", "pi_messages", "opencode_go"]) { assert.ok(API_STYLES.includes(style)); } diff --git a/apps/desktop/test/provider-endpoint-resolution-ui.test.mjs b/apps/desktop/test/provider-endpoint-resolution-ui.test.mjs index 9a808f8198..c830da54ed 100644 --- a/apps/desktop/test/provider-endpoint-resolution-ui.test.mjs +++ b/apps/desktop/test/provider-endpoint-resolution-ui.test.mjs @@ -96,10 +96,12 @@ test("the form adopts the address that answered and says what it detected", () = assert.match(setupSource, /resolveEndpointDraft\(/); assert.match(setupSource, /discovery\.effectiveBaseUrl/); // The answer belongs to the address it was produced for, so a URL typed since - // that probe is never reverted to an earlier result. + // that probe is never reverted to an earlier result. Classifier setup is an + // independent operation and must never adopt a chat discovery result. assert.match(setupSource, /const adoptedFrom = discovery\.resolvedFrom;/); + assert.match(setupSource, /systemOneSetup \|\| named \|\| !discoveredBaseUrl \|\| !adoptedFrom/); assert.match(setupSource, /endpointsEqual\(current, adoptedFrom\) \? discoveredBaseUrl : current/); - assert.match(setupSource, /\[named, discoveredBaseUrl, adoptedFrom\]/); + assert.match(setupSource, /\[systemOneSetup, named, discoveredBaseUrl, adoptedFrom\]/); assert.match(setupSource, /settings\.apiStyleAutoDetected/); }); diff --git a/apps/desktop/test/provider-form-layout.test.mjs b/apps/desktop/test/provider-form-layout.test.mjs index 925c421f97..c62a228d21 100644 --- a/apps/desktop/test/provider-form-layout.test.mjs +++ b/apps/desktop/test/provider-form-layout.test.mjs @@ -76,7 +76,7 @@ test("custom API format sits beside the key, not in a disclosure", () => { assert.match(setupSource, /provider-advanced-dialog/); assert.match(setupSource, /settings\.advancedSettings/); assert.match(fieldsSource, /settings\.apiStyle"/); - assert.match(fieldsSource, /CUSTOM_PROVIDER_API_STYLES\.map/); + assert.match(fieldsSource, /CUSTOM_PROVIDER_SETUP_API_STYLES/); assert.doesNotMatch(fieldsSource, /settings\.apiStyleDerived/); assert.match(fieldsSource, /provider-service-chip/); // The format choice belongs to the custom rows only. diff --git a/apps/desktop/test/settings-roundtrip.test.mjs b/apps/desktop/test/settings-roundtrip.test.mjs index 3c391a2fb5..a9c7bd1af9 100644 --- a/apps/desktop/test/settings-roundtrip.test.mjs +++ b/apps/desktop/test/settings-roundtrip.test.mjs @@ -34,3 +34,49 @@ test("invalid retry writes stay rejected at both boundaries", () => { assert.throws(() => main.validateSettingsWrite(settings), /infiniteProviderRetry is invalid/); } }); + +test("Jev classifier settings normalize safely and reject corrupt writes", () => { + const previousWindow = globalThis.window; + globalThis.window = { piDesktop: { platform: "win32" } }; + try { + const binding = { + baseUrl: "https://openrouter.test/api/v1/systemone/", + modelId: " ~typesafe/jev-latest ", + }; + const normalized = renderer.normalizeSettings({ + defaultMode: "agent", + jevEnabled: true, + jevClassifier: binding, + }); + assert.equal(normalized.jevEnabled, true); + assert.deepEqual(normalized.jevClassifier, { + baseUrl: "https://openrouter.test/api/v1", + modelId: "~typesafe/jev-latest", + }); + assert.deepEqual(main.validateSettingsWrite(structuredClone(normalized)).jevClassifier, + normalized.jevClassifier); + + for (const invalid of [null, {}, { baseUrl: "file:///tmp", modelId: "jev" }, + { baseUrl: "https://openrouter.test/api/v1", modelId: "two ids" }]) { + const stored = renderer.normalizeSettings({ + defaultMode: "agent", + jevEnabled: true, + jevClassifier: invalid, + }); + assert.equal(stored.jevEnabled, false, "corrupt explicit binding must fail closed"); + assert.equal(stored.jevClassifier, null); + } + assert.throws( + () => renderer.validateSettingsWrite({ jevClassifier: { baseUrl: "bad", modelId: "" } }), + /jevClassifier is invalid/, + ); + assert.throws( + () => main.validateSettingsWrite({ jevClassifier: { baseUrl: "bad", modelId: "" } }), + /jevClassifier is invalid/, + ); + assert.equal(renderer.validateSettingsWrite({ jevClassifier: null }).jevClassifier, null); + } finally { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + } +}); diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index 790e318d0b..1ecbfa6753 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -823,6 +823,12 @@ fn prompt_enhancement_template_error(field: &str, value: &Value) -> Option Value { if let Some(object) = value.as_object_mut() { object.remove("planApprovalPermissionMode"); + // `settings.set` is a shallow merge. A renderer that removes a custom + // Jev binding sends an explicit null; normalize it away after the merge + // so the stored absence again means the official legacy default. + if object.get("jevClassifier").is_some_and(Value::is_null) { + object.remove("jevClassifier"); + } if object.get("defaultMode").and_then(Value::as_str) == Some("chat") { object.insert("defaultMode".into(), Value::String("plan".into())); } @@ -1013,6 +1019,53 @@ fn validate_settings_value(value: &Value) -> Result<(), JsonRpcError> { )); } } + if let Some(binding) = object.get("jevClassifier").filter(|v| !v.is_null()) { + let Some(binding) = binding.as_object() else { + return Err(rpc_err( + 1002, + "jevClassifier must be an object", + "INVALID_PARAMS", + )); + }; + if binding.len() != 2 { + return Err(rpc_err( + 1002, + "invalid Jev classifier binding", + "INVALID_PARAMS", + )); + } + let base_url = binding + .get("baseUrl") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty() && value.len() <= 2048); + let model_id = binding + .get("modelId") + .and_then(Value::as_str) + .filter(|value| { + let value = value.trim(); + !value.is_empty() + && value.len() <= 256 + && !value + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control()) + }); + let valid_url = base_url + .and_then(|value| reqwest::Url::parse(value.trim()).ok()) + .is_some_and(|url| { + matches!(url.scheme(), "http" | "https") + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + }); + if !valid_url || model_id.is_none() { + return Err(rpc_err( + 1002, + "invalid Jev classifier binding", + "INVALID_PARAMS", + )); + } + } if let Some(binding) = object.get("imageGeneration").filter(|v| !v.is_null()) { for (key, max) in [("providerId", 128), ("modelId", 256)] { if !binding @@ -7536,6 +7589,68 @@ mod tests { assert!(catalog["effective"].is_object() || catalog["effective"].is_null()); } + #[tokio::test] + async fn jev_classifier_binding_round_trips_clears_and_validates() { + let data_dir = tempfile::tempdir().unwrap(); + let mut app_state = AppState::open(data_dir.path()).unwrap(); + app_state.handshook = true; + let state = Arc::new(Mutex::new(app_state)); + let (tx, _rx) = mpsc::unbounded_channel(); + + handle_request( + state.clone(), + "settings.set", + json!({ + "jevEnabled": true, + "jevClassifier": { + "baseUrl": "https://openrouter.test/api/v1", + "modelId": "~typesafe/jev-latest" + } + }), + tx.clone(), + ) + .await + .unwrap(); + let stored = handle_request(state.clone(), "settings.get", json!({}), tx.clone()) + .await + .unwrap(); + assert_eq!( + stored["jevClassifier"]["baseUrl"], + "https://openrouter.test/api/v1" + ); + assert_eq!(stored["jevClassifier"]["modelId"], "~typesafe/jev-latest"); + + handle_request( + state.clone(), + "settings.set", + json!({ "jevEnabled": false, "jevClassifier": null }), + tx.clone(), + ) + .await + .unwrap(); + let cleared = handle_request(state.clone(), "settings.get", json!({}), tx.clone()) + .await + .unwrap(); + assert!(cleared.get("jevClassifier").is_none()); + assert_eq!(cleared["jevEnabled"], false); + + for invalid in [ + json!({ "baseUrl": "file:///tmp", "modelId": "jev" }), + json!({ "baseUrl": "https://openrouter.test/api/v1", "modelId": "two ids" }), + json!({ "baseUrl": "https://user:secret@openrouter.test/api/v1", "modelId": "jev" }), + ] { + let error = handle_request( + state.clone(), + "settings.set", + json!({ "jevClassifier": invalid }), + tx.clone(), + ) + .await + .unwrap_err(); + assert_eq!(error.data.unwrap()["errorCode"], "INVALID_PARAMS"); + } + } + #[tokio::test] async fn prompt_enhancement_templates_round_trip_and_validate() { let data_dir = tempfile::tempdir().unwrap(); diff --git a/docs/spec/03-runtime/03-tools-and-permissions.md b/docs/spec/03-runtime/03-tools-and-permissions.md index b1b1c7f673..fb6a8558f9 100644 --- a/docs/spec/03-runtime/03-tools-and-permissions.md +++ b/docs/spec/03-runtime/03-tools-and-permissions.md @@ -59,7 +59,7 @@ their full schemas up front: - `BrowserPreview` - `PluginCheck`, `PluginScaffold`, and `PluginPack` - plugin-declared agent tools -- `JevClassify` when the user enables Jev and saves a TypeSafe API key +- `JevClassify` when the user enables Jev and saves a configured System One key These tools appear in a bounded `# On-demand tools` catalog with compact descriptions. The model calls the local `ToolSearch` tool with an exact name or @@ -77,11 +77,12 @@ tool is loaded. `ToolSearch` itself never executes a workspace operation and never bypasses host-core policy. `JevClassify` is offered only in Agent mode, only when explicitly enabled in -Settings → Models, and only while its TypeSafe key is available. It is a -structured classifier call, not a chat model or workspace operation. The Agent -must supply its JSON state and typed questions; those values go directly to -TypeSafe when the tool is called. The tool validates JSON shape and bounds the -payload to 24 KiB, uses the pi-ai `jev-latest` classifier with a 45-second +Settings → Models, and only while its configured System One key and binding are +available. It is a structured classifier call, not a chat model or workspace +operation. The Agent must supply its JSON state and typed questions; those +values go directly to the configured endpoint when the tool is called. The tool +validates JSON shape and bounds the payload to 24 KiB, uses pi-ai's native +System One classifier adapter with the configured model id and a 45-second timeout, and returns only structured answers and reported usage. Plan and Goal never receive the tool. The settings disclosure warns users not to pass secrets or personal information. diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index 30f0f76f88..b9c573f65f 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -229,13 +229,23 @@ PI-Desktop must not permanently restrict users to a short fixed model list. 5. Chat, image and classifier models are selected by operation type even when they share a model ID. pi-ai remains responsible for OAuth, wire identity, transport and typed non-chat operations that models.dev does not describe. - PI-Desktop's optional Jev integration uses pi-ai's built-in - `typesafe/jev-latest` classifier directly; it is never listed or selectable - as a chat provider/model, and it owns no provider row even though it is - added from the same service chooser. The key is checked against TypeSafe - over that same System One address before settings keeps it, so a saved key - and an explicit Jev setting together mean Agent mode receives the on-demand - `JevClassify` tool. + PI-Desktop's optional Jev integration remains an Agent-only `JevClassify` + tool. It owns no provider row and is never listed or selectable as a chat, + Composer, default or subagent model; Plan and Goal do not receive the tool. + The **TypeSafe (official)** setup tile keeps the default + `https://api.typesafe.ai/v1` API root and exact `jev-latest` model ID. + **Add service → Custom endpoint** additionally offers the setup-only + **System One (classifier)** API format. It collects a System One Base URL, + exact model ID and API key without adding that format to chat transports. + An API root or a full final `/systemone` operation URL is accepted and + normalized so probe and runtime requests append `/systemone` exactly once; + the configured model ID is sent unchanged in the request body. Settings + discloses that the classification state and questions go directly to the + configured System One endpoint. Save first probes that exact endpoint/model. + Only success stores the key in secure storage, writes the non-secret binding + to `AppSettings`, and enables Jev; failure or cancellation writes nothing. + This reuses existing settings and secret-storage contracts and changes no + database schema, Host protocol, plugin API or architecture. 6. Free-form IDs remain configurable. Conservative generic metadata applies when no published model matches; explicit user overrides remain supported. Unknown relay metadata uses exact final-segment matching and an unambiguous diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index 04da01524a..2dd23f3eab 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -525,9 +525,12 @@ a usage tab. Moonshot/Kimi, Zhipu, SiliconFlow, Volcengine Ark, MiniMax, MiniMax (OpenAI), Xiaomi, Kimi For Coding) then show Service + API key, with the published host as a - one-line summary. Custom endpoint then shows Service, Name beside Base URL, - and API key beside API format in three explicit rows so each input keeps a - stable alignment as the form changes. The custom Base URL field accepts + one-line summary. With a chat API format, Custom endpoint then shows Service, + Name beside Base URL, and API key beside API format in three explicit rows so + each input keeps a stable alignment as the form changes. Its API format menu + also offers the setup-only **System One (classifier)** choice on the add path; + that choice opens the classifier form below rather than creating a provider. + The custom Base URL field for chat formats accepts only http(s) service base URLs and trims pasted operation paths such as `/models`, `/messages`, `/chat/completions`, or `/responses` when the field loses focus. The placeholder is enough — no helper paragraph under the URL. @@ -578,30 +581,36 @@ contract modes are intent boundaries, not strict read-only security profiles. ### Jev classifier -- Keep Jev out of the chat provider/model list: it is a TypeSafe structured - classifier, not a conversation model, and it owns no provider row. -- Offer it from **Add service**, in its own Classifiers group, on the add path - only: changing an existing row's service never turns it into a classifier. - The tile carries the TypeSafe address the key is spent on. -- Adding Jev is one action with two steps: check the key against TypeSafe, then - store it and turn the classifier on. Nothing is written when the check fails, - and closing the dialog while the check is still running cancels the whole - action, so an enabled Jev always has a key the Agent can spend and an - abandoned dialog never leaves a credential behind. The credential is a - TypeSafe API key; this integration has no OAuth path. -- The model configuration page shows the Jev card only once Jev has been added: - an install without it has nothing there to configure, and adding stays in the - service chooser. The card carries the stored-key state, the Agent-mode switch - and the actions that replace or remove the key; removing the key takes the - switch down first, then the card. The key itself is entered only in the - service dialog, which is also where it is checked. It stays in Host secure - storage; the renderer only learns whether one exists. Keep the card free of - persistent explanatory paragraphs: its heading help icon provides integration - and data-handling details, while the switch help icon explains Agent-mode - availability. The setup form keeps the privacy notice visible before a key - is stored. -- The tool is on demand and unavailable in Plan and Goal modes. Removing the - key turns Jev off first, then deletes it. +- Jev remains an Agent-only `JevClassify` integration, not a conversation + provider or model. It owns no provider row, does not change the default chat + model, and never appears in Composer, default-model or Subagent model lists. + Plan and Goal do not expose the tool. +- **Add service** offers two add-only entries. The Classifiers group keeps a + **TypeSafe (official)** tile with defaults `https://api.typesafe.ai/v1` and + `jev-latest`. **Custom endpoint** adds the setup-only API format **System One + (classifier)**; changing an existing provider's service or API format cannot + turn that provider into a classifier. +- The custom classifier form asks for **System One Base URL**, **Model ID** and + **API key**. The URL accepts either an API root or the complete final + `/systemone` operation; normalization makes probe and runtime append that + segment exactly once. The model ID is sent exactly as entered after safe + trimming. The selected service chip shows the destination host, and visible + privacy copy states that `JevClassify` sends its state and questions directly + to the configured System One endpoint. +- Check and save is one ordered action: probe that exact endpoint/model, store + the key in Host secure storage, persist the non-secret endpoint/model binding + in `AppSettings`, then enable Jev. A refused probe writes neither secret nor + settings, and closing the dialog while the probe is in flight cancels with + zero writes. This path has no OAuth flow and does not create a chat provider. +- The model configuration page shows the Jev card only after a successful add. + The card carries stored-key state, the Agent-mode switch, and replace/remove + actions. The renderer learns only whether the key exists. The heading help + explains the integration and configured-endpoint data handling; the switch + help explains Agent-only availability. Removing the key first disables Jev + and clears its non-secret binding, then deletes the secret and removes the + card. +- The setup reuses existing `AppSettings` and secure storage. It introduces no + database schema, Host protocol, plugin API or architecture change. ### Agent capability destinations (Skills / MCP / Subagents) diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 13fd6a8c63..01b095c202 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10113,6 +10113,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | C / Quality — Self-hosted endpoint receives the session id | E2E-PROVIDER-custom-endpoint-session-identity | | B / F / Quality — Selected model order | E2E-MODEL-selected-order-persists | | E / F / Quality — MCP server timeout override | E2E-261 | +| B / E / F / Security / Quality — Jev System One classifier | E2E-JEV-SYSTEM-ONE | | A — App startup | E2E-001, E2E-002, E2E-003, E2E-004, E2E-067, E2E-076, E2E-079, E2E-092, E2E-097, E2E-143, E2E-150, E2E-168, E2E-204 | | A / C / F / Quality — Tray session navigation | E2E-TRAY-bounded-session-navigation | | B — Model config | E2E-005, E2E-PROVIDER-custom-endpoint-no-api-key, E2E-006, E2E-007, E2E-038, E2E-050, E2E-052, E2E-055, E2E-066, E2E-080, E2E-082, E2E-102c, E2E-102d, E2E-102e, E2E-151, E2E-154, E2E-163, E2E-166, E2E-172, E2E-174, E2E-197, E2E-005G, E2E-005J, E2E-199, E2E-201, E2E-202, E2E-203, E2E-205, E2E-206, E2E-209, E2E-PLUGIN-provider-catalog-add-service | @@ -10190,6 +10191,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | M6+ | E2E-121, E2E-122, E2E-148, E2E-150, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-166, E2E-168, E2E-173, E2E-174, E2E-176, E2E-179, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-202, E2E-203, E2E-205, E2E-209, E2E-SETTINGS-inline-capability-imports, E2E-210, E2E-UPDATE-preference-and-once-only-reminder, E2E-212, E2E-213, E2E-214, E2E-215, E2E-216, E2E-217, E2E-218, E2E-259, E2E-219, E2E-257, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation | | M6+ (Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | M6+ (Selected model order) | E2E-MODEL-selected-order-persists | +| M6+ (Jev System One classifier) | E2E-JEV-SYSTEM-ONE | | M6+ (MCP server timeout override) | E2E-261 | | M6+ (Destination loading and focus) | E2E-087b | | M6+ (Session list responsiveness) | E2E-SESSION-list-refresh-keeps-desktop-responsive | @@ -17975,49 +17977,6 @@ host-created files. The full app's file-preview viewer is covered separately. rejected submission does not. Deferred configuration alone does not count as usage. -- **Preconditions:** Isolated Electron profile, fake Host IPC/secret storage, - and a `globalThis.fetch` fixture for `https://api.typesafe.ai/v1/systemone`. - Build workspace JS packages with `pnpm build:js`, then run - `pnpm test:e2e:jev`. Do not use a real TypeSafe key or endpoint. -- **Steps:** 1) On the service chooser's add path, confirm Jev is offered in - its own Classifiers group and absent when an existing row changes service, - and that no Jev card is on the model configuration page yet. 2) Open the Jev - form, paste a sentinel key and Check and save: the fixture answers the check, - the key reaches Host secure storage, Jev is on, and the card appears. Confirm - AI services (with catalog status and refresh) precede Jev and image generation, - and that the card's privacy explanation is available from its heading help - control instead of as a persistent paragraph. - 3) Resolve a session launch with Jev enabled, then disabled and in Plan mode. - 4) Through the runtime's deferred catalog, request Jev in Agent mode and - inspect Plan/Goal catalogs. 5) Call `JevClassify` with one choice, one score - and one boolean question over a small JSON state. 6) Answer a check with 401 - for a second key: nothing is written and Jev stays off. 7) Start a check and - close the dialog while it is still in flight: the key is not stored and Jev - stays off. 8) In the Jev card, switch Jev off and remove the key; the card - leaves with it. -- **Expected:** The check runs before any write, in the order check, store, then - enable, so a refused key leaves no secret and no enabled setting behind, and - the refusal is reported with TypeSafe's status. The card is on the page only - once Jev has been added, and it leaves when the key does. The UI never returns - the key to settings state, and removal disables Jev before deleting it. Only - the Jev title, key status and controls occupy the card; explanatory privacy - copy appears on demand from the heading help control. AI services stay above - Jev, followed by image generation. An enabled Agent launch reads the key and - passes it ephemerally to the sidecar. - `JevClassify` appears in the Agent's deferred catalog only with a key and - never in Plan or Goal. Closing the dialog cancels an in-flight check the same - way a refused key does: nothing stored, nothing enabled. The fixture receives - the TypeSafe System One payload and bearer header; the tool returns bounded - structured answers and usage. -- **Specs:** [Tools and permissions](../03-runtime/03-tools-and-permissions.md), - [provider/model system](../03-runtime/11-provider-model-system.md), - [secrets storage](../03-runtime/14-secrets-storage.md), - [settings IA](../04-ux/06-settings-ia.md). -- **Acceptance:** No paid or real-provider call. The suite verifies the UI user - path, the ordered check-then-store gate, the refused-key path, the fixed - secret reference, opt-in Agent launch boundary, deferred mode catalog, - request body, bearer auth, usage, error redaction, cancellation, timeout, - malformed and oversized input rejection, and key removal. - Disable/remove a provider or model and mark a model for image generation: unavailable history entries are skipped for inheritance and recent menu rows. - Settings contains no fixed chat-default picker or Make default service action; @@ -18025,6 +17984,65 @@ host-created files. The full app's file-preview viewer is covered separately. - Coverage: recent-models.test.mjs, recent-model-flow.test.mjs, default-model-picker.test.mjs, and scripts/e2e-composer-model-selection.mjs. +#### E2E-JEV-SYSTEM-ONE: Official and custom classifier setup stays isolated from chat + +- **Preconditions:** Isolated Electron profiles, fake Host IPC/secure storage, + and an offline `globalThis.fetch` fixture. It serves the official + `https://api.typesafe.ai/v1/systemone` route and an OpenRouter-style custom + route whose configured API root is `https://openrouter.test/api/v1`, model is + `~typesafe/jev-latest`, and operation URL is + `https://openrouter.test/api/v1/systemone`. Build workspace JS packages with + `pnpm build:js`, then run `pnpm test:e2e:jev`. Real services, real credentials + and paid requests are forbidden. +- **Steps:** 1) Record the existing provider rows, default chat model, + default-model options, Composer model options and Subagent model options. Open + **Add service** and confirm the + Classifiers group contains **TypeSafe (official)** with the default root and + `jev-latest`; confirm **Custom endpoint** offers the setup-only **System One + (classifier)** API format only on the add path. No Jev card exists yet. + 2) In separate clean cases, enter first the custom API root and then the full + `https://openrouter.test/api/v1/systemone` operation URL, each with the exact + model ID and a sentinel key. Confirm the service summary and privacy copy + disclose the configured System One destination. Return 401 from the root-URL + probe; leave the full-URL probe in flight and close the dialog. Snapshot fake + secure storage and `AppSettings` before and after each case. 3) Repeat both + URL forms with successful probes. Assert each probe reaches the fixture before + any write, then the key enters secure storage, the non-secret binding enters + `AppSettings`, Jev is enabled, and its card appears. + 4) Resolve an Agent launch, call `JevClassify` with one choice, + one score and one boolean question over a small JSON state, then resolve with + Jev disabled and inspect Plan/Goal catalogs. 5) Reopen model selectors and + provider settings, then disable and remove Jev. Run the official tile happy + path in a separate isolated case to preserve its default route/model coverage. +- **Expected:** The API-root and complete-operation inputs normalize to the same + target. Both probe and runtime requests append `/systemone` exactly once; + neither requests `/systemone/systemone`. The custom fixture receives the + precise URL `https://openrouter.test/api/v1/systemone`, bearer key, System One + payload and exact model `~typesafe/jev-latest`. The official case receives + `jev-latest` at its default route. A refusal or cancellation performs zero + secure-storage and `AppSettings` writes and leaves Jev off. A successful add + creates no chat provider, preserves every provider row and the default chat + model, and never adds the classifier to default-model, Composer or Subagent + model options. + `JevClassify` appears only in the enabled Agent deferred catalog, never in + Plan or Goal, and returns bounded structured answers and usage. Removal + disables and clears the binding before deleting the key; the card then leaves. +- **Specs:** [Tools and permissions](../03-runtime/03-tools-and-permissions.md), + [provider/model system](../03-runtime/11-provider-model-system.md), + [secrets storage](../03-runtime/14-secrets-storage.md), + [settings IA](../04-ux/06-settings-ia.md). +- **Acceptance:** No real-provider or paid call. The suite covers the complete + add path, official defaults, custom target disclosure, exact URL/model wire + values, ordered probe/store/settings enablement, failure and cancellation + atomicity, secure-key redaction, Agent-only catalog boundary, chat/provider/ + default/Composer/Subagent isolation, usage, timeout and malformed/oversized + input rejection, and ordered removal. +- **Milestone:** M6+ maintenance. +- **Status:** Automated offline coverage via `pnpm test:e2e:jev`; the UI journey + uses isolated Electron profiles and fake Host IPC, while shared/Main/runtime + tests cover binding validation, Host persistence, launch isolation, exact + transport bytes, timeout, cancellation and redirect refusal. + #### E2E-CHAT-subagent-direct-stop diff --git a/docs/zh-CN/spec/03-runtime/03-tools-and-permissions.md b/docs/zh-CN/spec/03-runtime/03-tools-and-permissions.md index 0eab90abf6..30c9c2291b 100644 --- a/docs/zh-CN/spec/03-runtime/03-tools-and-permissions.md +++ b/docs/zh-CN/spec/03-runtime/03-tools-and-permissions.md @@ -60,6 +60,7 @@ Plan 和 Goal 保留其 read/inspection 核心。`Skill` 有意不作延迟:`/ - `BrowserPreview` - `PluginCheck`、`PluginScaffold` 和 `PluginPack` - 插件声明的代理工具 +- 用户启用 Jev 并保存已配置的 System One 密钥时的 `JevClassify` 这些工具出现在有界的 `# On-demand tools` 目录中,具有紧凑的结构 描述。该模型使用确切的名称调用本地 `ToolSearch` 工具或 @@ -73,6 +74,14 @@ sidecar 在每个新用户提示开始时重置此延迟集,并从有效会话 加载工具时不会改变。`ToolSearch` 本身从不执行工作区操作并且永远不会 绕过 host-core 策略。 +`JevClassify` 仅在 Agent 模式下提供,并且必须在设置 → 模型中显式启用, +同时已配置的 System One 密钥和 binding 均可用。它是结构化分类器调用, +不是聊天模型或工作区操作。Agent 必须提供 JSON 状态和类型化问题;调用工具时, +这些值会直接发送到已配置的端点。该工具校验 JSON 形状并将负载限制为 24 KiB, +通过 pi-ai 原生 System One 分类器适配器使用已配置的模型 ID 和 45 秒超时, +仅返回结构化答案和上报的用量。Plan 和 Goal 均不提供该工具。设置中的披露文案 +会警告用户不要传递密钥或个人信息。 + ## 3. 常用工具约束 每个非交互式执行工具都必须具有: @@ -88,6 +97,10 @@ sidecar 在每个新用户提示开始时重置此延迟集,并从有效会话 渲染器响应没有过期,并返回有界结构化工具 结果。停止回合可以解决跳过的未决问题。 +`JevClassify` 是外部提供商操作,而不是工作区执行工具。它会校验结构化请求、 +传播取消、使用有界的提供商超时,并返回类型化分类结果;它没有文件系统路径或 +host-core 工作区权限表面。 + ## 4. 路径规则 本机文件和搜索工具强制执行不同的路径形状(D208、ADR 0069): diff --git a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md index dec4160c99..2dc26d9239 100644 --- a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md +++ b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md @@ -203,15 +203,22 @@ PI-Desktop 不得把用户永久限制在一份简短的固定模型列表上。 projected once at the account boundary and reused for ordinary sessions, delegates, compaction and image lookup. Pi's unsupported/null effort mappings cannot be re-enabled by stale persisted settings. No saved data is rewritten. -5. Chat, image and classifier models are selected by operation type even when - they share a model ID. A small display-only operation metadata supplement - preserves settings visibility for image/audio/video/embedding records that - Pi does not publish. It supplies no runtime auth, dispatch, price or entitlement. -6. Free-form IDs remain configurable. Conservative generic metadata applies - when no published model matches; explicit user overrides remain supported. - Unknown relay metadata uses exact final-segment matching and an unambiguous - official publisher. Known endpoint aliases cannot borrow past an ambiguous - same-endpoint miss. No deployment/date/thinking suffix is removed. +5. 即使聊天、图像和分类器使用相同模型 ID,也必须按操作类型分别选择。一个仅用于展示的 + 小型操作元数据补充表,继续保证 Pi 未发布的图像/音频/视频/嵌入记录能在设置中显示; + 它不提供运行时认证、分派、价格或权限。PI-Desktop 的可选 Jev 集成始终只是 Agent 模式的 + `JevClassify` 工具:它不拥有提供商行,也不会出现在聊天、Composer、默认模型或子智能体 + 模型列表中;Plan 和 Goal 不会获得该工具。**TypeSafe(官方)**设置卡片继续使用默认 + API 根地址 `https://api.typesafe.ai/v1` 和精确模型 ID `jev-latest`。 + **添加服务 → 自定义端点**的接口格式另提供仅用于设置的 **System One(分类器)**。 + 选择后填写 System One 基础 URL、精确模型 ID 和 API 密钥,不会把该格式加入聊天传输。 + 基础 URL 可填写 API 根地址或末尾带完整 `/systemone` 的操作地址;探测与运行时会规范化地址, + 确保 `/systemone` 只追加一次,并在请求正文中原样发送配置的模型 ID。设置界面明确告知用户, + 分类状态与问题会直接发往已配置的 System One 端点。保存前先探测这一精确端点/模型;只有 + 成功后才把密钥写入安全存储、把非敏感绑定写入 `AppSettings` 并启用 Jev,失败或取消均零写入。 + 此功能复用现有设置与密钥存储契约,不改变数据库架构、Host 协议、插件 API 或系统架构。 +6. 目录未发布的模型仍可自由填写 ID。没有发布记录时使用保守的通用元数据,并继续支持 + 用户显式覆盖。未知中转模型只按精确的末段 ID 和无歧义的官方发布方匹配;已知端点别名 + 遇到同端点歧义缺失时也不能借用其他记录。不得通过删除部署、日期或思考后缀来制造匹配。 7. Settings catalog refresh calls Pi's public refresh API. Release scripts no longer fetch or package the independent Pi catalog JSON catalog. Pi version pins provide the reproducible catalog baseline. diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md index 88b7a4b587..fd1205a3e3 100644 --- a/docs/zh-CN/spec/04-ux/06-settings-ia.md +++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md @@ -212,6 +212,7 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 按来源分组显示草稿,且不渲染原始凭据;已存储的 API 密钥写入宿主密钥库, 订阅/OAuth 登录不导入,等价提供商会跳过。 - 新建对话框先只显示**服务**。命名端点按厂商平铺(OpenAI、Anthropic、Google、OpenRouter、DeepSeek、通义千问、月之暗面、智谱、硅基流动、火山方舟、MiniMax、MiniMax (OpenAI)、小米、Kimi 编程等),不再分国际 / 国内。选中后显示服务 + API 密钥和主机摘要。自定义端点随后分三行显示服务、名称与接口地址、以及 API 密钥与接口格式,让字段在表单变化时保持稳定对齐;接口地址不附带解释性说明。获取模型失败时,分类结论以一条 toast 报出,空列表处只保留一行「无列表」文案,已有缓存列表时列表原样保留;两种情况都不展示原始 HTTP/JSON 报错。探测结论(已连接、来自目录或被拒绝)只播报一次,密钥下方不再保留状态行。名称(命名行)与可选自定义请求头放在高级设置中。对话框右上角提供明确的“高级设置”按钮,点击后打开独立紧凑弹框,主表单只保留端点和模型面板。弹框可直接添加包含 User-Agent 在内的常用请求头,可将与持久化相同的规范化请求头 JSON 复制到剪贴板(忽略空名称,后者覆盖前者),也支持导入直接请求头对象或 `{ "headers": { ... } }` JSON;导入时按名称合并,不重复添加。列表最多显示五行,更多请求头在自身区域滚动;留空则使用适配器默认值。服务是可搜索锚定菜单(按显示名、厂商、主机过滤),不是原生下拉框。 + - 自定义端点的接口格式在新增路径额外提供仅用于设置的 **System One(分类器)**;选择后进入下述分类器表单,而不是创建聊天提供商。编辑已有提供商不能把它转换为分类器。 - 带有头像缩写、主机、默认模型、秘密状态的提供商卡, 和测试/make-default/删除操作 - add/edit 对话框配置连接身份(名称、端点、API 样式、模型 ID 和密码); @@ -221,6 +222,18 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 - API 键在保存后永远不会显示原始内容 - 已登录的厂商行带账户徽标,其编辑对话框用该徽标替换 API 密钥字段 —— 这里没有东西可粘贴 + +### Jev 分类器 + +- Jev 始终只是 Agent 模式的 `JevClassify` 集成,不是聊天提供商或聊天模型。它不创建提供商行、不改变默认聊天模型,也不会出现在 Composer、默认模型或子智能体模型列表中;Plan 和 Goal 不暴露该工具。 +- **添加服务**提供两条仅用于新增的入口:分类器分组保留 **TypeSafe(官方)**卡片,默认值为 `https://api.typesafe.ai/v1` 与 `jev-latest`;**自定义端点**的接口格式增加 **System One(分类器)**。 +- 自定义分类器表单填写 **System One 基础 URL**、**模型 ID**和 **API 密钥**。URL 可使用 API 根地址或末尾带完整 `/systemone` 的操作地址;规范化后探测和运行时只追加一次该路径。安全去除首尾空白后,模型 ID 在请求正文中原样发送。服务摘要显示目标主机,表单中的隐私说明明确告知用户:`JevClassify` 的状态和问题会直接发往已配置的 System One 端点。 +- “测试并保存”严格按顺序执行:探测这一精确端点/模型,将密钥写入 Host 安全存储,把非敏感端点/模型绑定写入 `AppSettings`,然后启用 Jev。探测被拒绝不会写入密钥或设置;探测过程中关闭对话框会取消操作并保持零写入。此路径没有 OAuth,也不会创建聊天提供商。 +- 只有成功添加后,模型配置页才显示 Jev 卡片。卡片包含密钥是否存在、Agent 模式开关以及更换/移除操作,Renderer 永远拿不到密钥原文。标题帮助说明集成及已配置端点的数据处理,开关帮助说明仅 Agent 可用。移除时先停用 Jev 并清除非敏感绑定,再删除密钥和卡片。 +- 此设置复用现有 `AppSettings` 与安全存储,不改变数据库架构、Host 协议、插件 API 或系统架构。 + +### Agent 能力入口(Skills / MCP / 子智能体) + - 列表仍分两组:内置五个默认子智能体(`explorer`、`code-reviewer`、`test-runner`、 `fixer`、`ui-designer`)和 `~/.agents/subagents` 下的用户文档。同名的已启用用户文档 会在 `Task` 目录中遮蔽对应的内置定义,内置行随之省略、只保留用户行;同名的已停用用户 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index bfbfd76b2c..b423c5a28f 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5740,6 +5740,7 @@ eleven-tool-round desktop paths are verified by | B / F / Security — 提供商复制 | E2E-PROVIDER-copy-config-without-credentials | | C / Quality — 自建端点收到会话 id | E2E-PROVIDER-custom-endpoint-session-identity | | B / F / Quality — 已选模型顺序 | E2E-MODEL-selected-order-persists | +| B / E / F / 安全性 / 品质 — Jev System One 分类器 | E2E-JEV-SYSTEM-ONE | | E / F / Quality — MCP 服务器超时覆盖 | E2E-261 | | C / D / Quality — 折叠项目后重置会话历史 | E2E-263 | | A — 应用程序启动 | E2E-001、E2E-002、E2E-003、E2E-004、E2E-067、E2E-076、E2E-079、E2E-092、E2E-097、E2E-143、E2E-150、E2E-168、E2E-204、E2E-217 | @@ -5816,6 +5817,7 @@ eleven-tool-round desktop paths are verified by | M5(Skill 常驻) | E2E-254 | | M6 | E2E-104、E2E-105、E2E-106、E2E-107、E2E-108、E2E-109、E2E-110、E2E-111、E2E-112、E2E-113、E2E-114、E2E-115、E2E-116、E2E-117、 E2E-118、E2E-119、E2E-120、E2E-103 | | M6+ | E2E-121、E2E-122、E2E-123、E2E-142、E2E-148、E2E-150、E2E-151、E2E-168、E2E-199、E2E-200、E2E-202、E2E-203、E2E-209、E2E-211、E2E-UPDATE-preference-and-once-only-reminder、E2E-212、E2E-213、E2E-214、E2E-215、E2E-216、E2E-217、E2E-257、E2E-SETTINGS-inline-capability-imports、E2E-166、E2E-SUBAGENT-resume-a-settled-delegation | +| M6+(Jev System One 分类器) | E2E-JEV-SYSTEM-ONE | | M6+(Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | M6+(已选模型顺序) | E2E-MODEL-selected-order-persists | | M6+(MCP 服务器超时覆盖) | E2E-261 | @@ -9604,6 +9606,17 @@ Follow the [current S3 acceptance scenario](/spec/06-delivery/04-e2e-test-plan#e - **状态:** `pnpm test:e2e:provider-api-style`、`official-native-search.test.ts`; 共享路由测试覆盖伪装域名、不安全地址和未知中转站。未验证线上服务或 Host/SQLite 保存。 + +#### E2E-JEV-SYSTEM-ONE:官方与自定义分类器设置不进入聊天模型体系 + +- **前提:** 使用隔离的 Electron 配置文件、伪造 Host IPC/安全存储和完全离线的 `globalThis.fetch` fixture。fixture 同时提供官方 `https://api.typesafe.ai/v1/systemone` 路由,以及 OpenRouter 风格的自定义路由:配置的 API 根地址为 `https://openrouter.test/api/v1`、模型为 `~typesafe/jev-latest`、最终操作地址为 `https://openrouter.test/api/v1/systemone`。先执行 `pnpm build:js`,再执行 `pnpm test:e2e:jev`。禁止使用真实服务、真实凭据或付费请求。 +- **步骤:** 1)记录已有提供商行、默认聊天模型、默认模型选项、Composer 模型选项和子智能体模型选项。打开**添加服务**,确认分类器分组包含 **TypeSafe(官方)**及默认根地址和 `jev-latest`;确认**自定义端点**仅在新增路径提供仅用于设置的 **System One(分类器)**接口格式,且此时尚无 Jev 卡片。2)分别在干净用例中填写自定义 API 根地址和完整的 `https://openrouter.test/api/v1/systemone` 操作地址,两者都配精确模型 ID 和哨兵密钥;确认服务摘要和隐私说明准确披露已配置的 System One 发送目标。让根地址用例的探测返回 401;让完整地址用例保持探测未完成并关闭对话框;分别比较每次操作前后的伪安全存储和 `AppSettings` 快照。3)对两种 URL 写法分别重复成功探测,断言任何写入前都先命中 fixture,随后密钥进入安全存储、非敏感绑定进入 `AppSettings`、Jev 被启用且卡片出现。4)解析 Agent 启动,用一项选择题、一项评分题、一项是非题和小型 JSON 状态调用 `JevClassify`;再分别在关闭 Jev 及 Plan/Goal 模式下解析目录。5)重新打开模型选择器和提供商设置,再关闭并移除 Jev。另用隔离用例走通官方卡片,保留默认路由/模型覆盖。 +- **预期:** API 根地址和完整操作地址会规范化到同一目标;探测和运行时都只追加一次 `/systemone`,绝不请求 `/systemone/systemone`。自定义 fixture 收到精确地址 `https://openrouter.test/api/v1/systemone`、Bearer 密钥、System One 负载以及精确模型 `~typesafe/jev-latest`;官方用例在默认路由收到 `jev-latest`。探测失败或取消时,安全存储与 `AppSettings` 均零写入,Jev 保持关闭。成功添加不会创建聊天提供商,所有提供商行和默认聊天模型保持不变,分类器也不会加入默认模型、Composer 或子智能体模型选项。`JevClassify` 只在已启用的 Agent 延迟目录中出现,Plan 与 Goal 都不暴露,并返回有界结构化答案和用量。移除时先停用并清除绑定,再删除密钥,卡片随后消失。 +- **规格:** [工具与权限](../03-runtime/03-tools-and-permissions.md)、[提供商/模型系统](../03-runtime/11-provider-model-system.md)、[密钥存储](../03-runtime/14-secrets-storage.md)、[设置 IA](../04-ux/06-settings-ia.md)。 +- **验收:** 不发生真实提供商或付费调用。套件覆盖完整新增路径、官方默认值、自定义目标披露、精确 URL/模型线值、探测→安全存储→设置启用的顺序、失败与取消的原子性、密钥脱敏、仅 Agent 的目录边界、聊天提供商/默认模型/Composer/子智能体隔离、用量、超时、畸形或超大输入拒绝,以及有序移除。 +- **里程碑:** M6+ 维护。 +- **状态:** 通过 `pnpm test:e2e:jev` 完成离线自动化覆盖;UI 用户路径使用隔离 Electron 配置与伪 Host IPC,共享/Main/Runtime 测试覆盖绑定校验、Host 持久化、启动隔离、精确传输字节、超时、取消与重定向拒绝。 + #### E2E-260:Windows 更新缓存可安全迁移和回收 - **前提:** 已打包的 Windows x64 NSIS 安装、隔离用户配置,以及非系统卷上的可写缓存目录。使用本地更新源 fixture;不得访问 GitHub 或付费服务。 diff --git a/packages/agent-runtime/src/jev-classifier-tool.test.ts b/packages/agent-runtime/src/jev-classifier-tool.test.ts index 0a817b3af6..1adadf3144 100644 --- a/packages/agent-runtime/src/jev-classifier-tool.test.ts +++ b/packages/agent-runtime/src/jev-classifier-tool.test.ts @@ -85,6 +85,47 @@ describe("JevClassify", () => { } }); + it("uses a custom System One root and exact classifier id without doubling the route", async () => { + const fetchMock = vi.fn(async () => successResponse()); + vi.stubGlobal("fetch", fetchMock); + try { + const result = await createJevClassifierTool({ + apiKey: API_KEY, + baseUrl: "https://openrouter.test/api/v1", + modelId: "~typesafe/jev-latest", + }).execute("call-custom", params); + + expect(result.isError).not.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe("https://openrouter.test/api/v1/systemone"); + expect(JSON.parse(String(init?.body)).model).toBe("~typesafe/jev-latest"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("refuses to carry the classifier credential across origins", async () => { + const fetchMock = vi.fn(async () => new Response(null, { + status: 307, + headers: { location: "https://attacker.test/systemone" }, + })); + vi.stubGlobal("fetch", fetchMock); + try { + const result = await createJevClassifierTool({ + apiKey: API_KEY, + baseUrl: "https://openrouter.test/api/v1", + modelId: "~typesafe/jev-latest", + }).execute("call-redirect", params); + + expect(result.isError).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(JSON.stringify(result)).not.toContain(API_KEY); + } finally { + vi.unstubAllGlobals(); + } + }); + it("rejects malformed and oversized inputs before making a request", async () => { const fetchMock = vi.fn(async () => successResponse()); vi.stubGlobal("fetch", fetchMock); diff --git a/packages/agent-runtime/src/jev-classifier-tool.ts b/packages/agent-runtime/src/jev-classifier-tool.ts index 19bda46729..af121b158f 100644 --- a/packages/agent-runtime/src/jev-classifier-tool.ts +++ b/packages/agent-runtime/src/jev-classifier-tool.ts @@ -9,6 +9,11 @@ import type { } from "@earendil-works/pi-ai"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Type } from "typebox"; +import { + DEFAULT_JEV_CLASSIFIER_BINDING, + jevSystemOneUrl, + type JevClassifierRuntimeConfig, +} from "@pi-desktop/shared"; const MAX_CONTEXT_BYTES = 24 * 1024; const MAX_JSON_DEPTH = 8; @@ -19,7 +24,48 @@ const MAX_TEXT_LENGTH = 1_200; const models = createModels(); models.setProvider(typesafeProvider()); -const jevModel = TYPESAFE_CLASSIFIER_MODELS["jev-latest"]; +const defaultJevModel = TYPESAFE_CLASSIFIER_MODELS["jev-latest"]; +const MAX_REDIRECTS = 3; + +function configuredJev(input: string | JevClassifierRuntimeConfig): JevClassifierRuntimeConfig { + return typeof input === "string" + ? { ...DEFAULT_JEV_CLASSIFIER_BINDING, apiKey: input } + : input; +} + +/** Manual redirect handling so Bearer auth never crosses the configured origin. */ +function sameOriginClassifierFetch(operationUrl: string): typeof fetch { + const allowedOrigin = new URL(operationUrl).origin; + return async (input, init) => { + let url = input instanceof Request ? input.url : String(input); + if (new URL(url).origin !== allowedOrigin) { + throw new Error("System One request refused: it left the configured origin"); + } + const requestInit: RequestInit = input instanceof Request + ? { + method: input.method, + headers: input.headers, + ...(input.method !== "GET" && input.method !== "HEAD" + ? { body: await input.clone().arrayBuffer() } + : {}), + signal: input.signal, + ...init, + redirect: "manual", + } + : { ...init, redirect: "manual" }; + for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { + const response = await globalThis.fetch(url, requestInit); + const location = response.headers.get("location"); + if (response.status < 300 || response.status >= 400 || !location) return response; + const next = new URL(location, url); + if (next.origin !== allowedOrigin) { + throw new Error("System One redirect refused: it left the configured origin"); + } + url = next.toString(); + } + throw new Error("System One request followed too many redirects"); + }; +} const choiceQuestionSchema = Type.Object( { @@ -160,13 +206,22 @@ function parseContext(value: unknown): ClassifierContext | undefined { return context; } -/** Agent-only, deferred call to pi-ai's TypeSafe System One classifier. */ -export function createJevClassifierTool(apiKey: string): AgentTool { +/** Agent-only, deferred call to the configured System One classifier. */ +export function createJevClassifierTool( + input: string | JevClassifierRuntimeConfig, +): AgentTool { + const config = configuredJev(input); + const jevModel = { + ...defaultJevModel, + id: config.modelId, + baseUrl: config.baseUrl, + }; + const operationUrl = jevSystemOneUrl(config); return { name: "JevClassify", label: "Jev classify", description: - "Classify JSON state with TypeSafe Jev and return structured choice, score, or boolean answers. The state and questions are sent directly to TypeSafe; include only information the user has approved for that service.", + "Classify JSON state with the configured System One classifier and return structured choice, score, or boolean answers. The state and questions are sent to that configured endpoint; include only information the user has approved for that service.", parameters: Type.Object( { state: Type.Record(Type.String(), Type.Any()), @@ -201,14 +256,15 @@ export function createJevClassifierTool(apiKey: string): AgentTool { ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; const result = await models.classify(jevModel, context, { - apiKey, + apiKey: config.apiKey, + fetch: sameOriginClassifierFetch(operationUrl), signal: requestSignal, timeoutMs: 45_000, maxRetries: 1, }); if (result.stopReason !== "stop") { const errorMessage = (result.errorMessage ?? "Request did not complete") - .replaceAll(apiKey, "[redacted]") + .replaceAll(config.apiKey, "[redacted]") .slice(0, 1_000); return { content: [{ type: "text", text: `Jev classification failed: ${errorMessage}` }], diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index aeeeedb62a..673eca3ac1 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -57,10 +57,12 @@ import { import { DEFAULT_COMMAND_TIMEOUT_MS, OAUTH_AUTH_KIND, + DEFAULT_JEV_CLASSIFIER_BINDING, type TrustedExtensionCommand, type TrustedExtensionDiagnostic, type TrustedExtensionSpec, type TrustedExtensionUiResponse, + type JevClassifierRuntimeConfig, } from "@pi-desktop/shared"; import type { TrustedExtensionRunner, @@ -978,7 +980,9 @@ export type AgentRuntimeOptions = { thinkingLevel: SessionThinkingLevel; /** Persisted opt-in for retrying transient provider failures until success. */ infiniteProviderRetry?: boolean; - /** TypeSafe Jev credential, resolved by Electron main only when enabled. */ + /** Complete System One classifier configuration resolved by Electron main. */ + jev?: JevClassifierRuntimeConfig; + /** @deprecated Official-default compatibility for older callers/tests. */ jevApiKey?: string; systemPrompt?: string; /** pi-compatible SYSTEM.md / APPEND_SYSTEM.md resolved for the session (issue #542). */ @@ -1052,6 +1056,7 @@ export type RuntimeMatchConfig = { provider: RuntimeProviderConfig; thinkingLevel: SessionThinkingLevel; jevApiKey?: string; + jev?: JevClassifierRuntimeConfig; pluginTools?: PluginToolDef[]; pluginSkills?: PluginSkillDef[]; trustedExtensions?: TrustedExtensionSpec[]; @@ -1920,7 +1925,7 @@ export class DesktopAgentRuntime { private providerRateLimitRetryAttempt = 0; /** Opt-in mode removes only the retry-count ceiling; abort and backoff stay intact. */ private infiniteProviderRetry = false; - private jevApiKey?: string; + private jev?: JevClassifierRuntimeConfig; private activeProviderRetryAttempt = 0; private providerRetryInProgress = false; private suppressProviderRetryRunEnd = false; @@ -2023,7 +2028,9 @@ export class DesktopAgentRuntime { this.provider = opts.provider; this.thinkingLevel = clampThinkingLevel(opts.provider, opts.thinkingLevel); this.infiniteProviderRetry = opts.infiniteProviderRetry === true; - this.jevApiKey = opts.jevApiKey; + this.jev = opts.jev ?? (opts.jevApiKey + ? { ...DEFAULT_JEV_CLASSIFIER_BINDING, apiKey: opts.jevApiKey } + : undefined); this.host = opts.host; this.onDiagnostic = opts.onDiagnostic ?? (() => undefined); this.hostCloseUnsubscribe = this.host.onClose?.(() => { @@ -2708,7 +2715,9 @@ Do not invent objections or turn speculative risks into blockers. Stop when the !this.disposed && providerMatches && this.mode === config.mode && - this.jevApiKey === config.jevApiKey && + this.jev?.apiKey === (config.jev?.apiKey ?? config.jevApiKey) && + this.jev?.baseUrl === (config.jev?.baseUrl ?? (config.jevApiKey ? DEFAULT_JEV_CLASSIFIER_BINDING.baseUrl : undefined)) && + this.jev?.modelId === (config.jev?.modelId ?? (config.jevApiKey ? DEFAULT_JEV_CLASSIFIER_BINDING.modelId : undefined)) && this.thinkingLevel === clampThinkingLevel(config.provider, config.thinkingLevel) && current === next && @@ -3867,8 +3876,8 @@ Do not invent objections or turn speculative risks into blockers. Stop when the } const builtins = tools.map(exec); const jevTools: AgentTool[] = - this.mode === "agent" && this.jevApiKey - ? [createJevClassifierTool(this.jevApiKey)] + this.mode === "agent" && this.jev + ? [createJevClassifierTool(this.jev)] : []; // Plugins contribute Agent tools by default. Plan/Goal modes only diff --git a/packages/agent-runtime/src/sidecar.ts b/packages/agent-runtime/src/sidecar.ts index c591dabaf4..aafbe4b513 100644 --- a/packages/agent-runtime/src/sidecar.ts +++ b/packages/agent-runtime/src/sidecar.ts @@ -106,8 +106,10 @@ type RuntimeParams = { turnId?: string; thinkingLevel?: SessionThinkingLevel; infiniteProviderRetry?: boolean; - /** Opt-in TypeSafe classifier credential resolved by Electron main. */ + /** @deprecated Official-default compatibility for older Main callers/tests. */ jevApiKey?: string; + /** Complete configured System One identity plus ephemeral credential. */ + jev?: import("@pi-desktop/shared").JevClassifierRuntimeConfig; provider: RuntimeProviderConfig; commandShell: CommandShellOption; pluginTools?: PluginToolDef[]; @@ -235,6 +237,7 @@ async function runtimeFor( subagentProviders, subagentModelKeys, jevApiKey: params.jevApiKey, + jev: params.jev, projectInstructions: params.projectInstructions, customSystemPrompt: params.customSystemPrompt, projectMemory: params.projectMemory, @@ -302,6 +305,7 @@ async function runtimeFor( thinkingLevel, infiniteProviderRetry: params.infiniteProviderRetry === true, jevApiKey: params.jevApiKey, + jev: params.jev, history, compaction, compactionSettings: params.compactionSettings, diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 9b951917b4..73bfd42d8f 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1521,6 +1521,7 @@ sklm: { "apiStyle": "API-Format", "apiStyleChatCompletions": "OpenAI-Chat-Abschlüsse", "apiStyleOpenCodeGo": "OpenCode Go", + "apiStyleSystemOne": "System One (Klassifikator)", "service": "Dienst", "searchService": "Dienste filtern", "noServiceMatches": "Keine passenden Dienste.", @@ -1640,14 +1641,19 @@ sklm: { "catalogStatusLine": "Katalog: {{source}} · {{models}} Modelle · aktualisiert {{fetchedAt}}", "catalogStatusUnknown": "Modellkatalogstatus nicht verfügbar", "jevTitle": "Strukturierter Jev-Klassifikator", - "jevDescription": "Richte TypeSafe Jev ein, einen strukturierten Klassifikator für Auswahl-, Bewertungs- und Ja/Nein-Fragen des Agenten.", + "jevOfficialService": "TypeSafe (offiziell)", + "jevDescription": "Richten Sie einen System One-Endpunkt für Jev ein. Dieser strukturierte Klassifikator beantwortet Auswahl-, Bewertungs- und Ja/Nein-Fragen für den Agenten.", + "jevBaseUrl": "System One-Basis-URL", + "jevModelId": "Modell-ID", "jevEnable": "Jev für den Agenten aktivieren", "jevEnableDescription": "Jev wird als Werkzeug auf Abruf im Agent-Modus verfügbar. In Plan und Goal ist es nicht verfügbar.", - "jevPrivacyNotice": "Bei JevClassify werden der übergebene Zustand und die Fragen direkt an TypeSafe gesendet. Keine Geheimnisse oder persönlichen Daten übermitteln.", - "jevApiKey": "TypeSafe-API-Schlüssel", - "jevApiKeyPlaceholder": "TypeSafe-API-Schlüssel einfügen", + "jevClassifierOnlyNote": "Dieser Klassifikator dient ausschließlich JevClassify. Er erscheint weder in den Chat- oder Standardmodelllisten noch in den Modelllisten für Subagenten.", + "jevPrivacyNotice": "Bei JevClassify werden der übergebene Zustand und die Fragen direkt an den konfigurierten System One-Endpunkt gesendet. Keine Geheimnisse oder persönlichen Daten übermitteln.", + "jevApiKey": "System One-API-Schlüssel", + "jevApiKeyPlaceholder": "System One-API-Schlüssel einfügen", "jevKeyConfigured": "API-Schlüssel sicher gespeichert", "jevKeyMissing": "Kein API-Schlüssel konfiguriert", + "jevBindingInvalid": "Die Klassifikator-Konfiguration ist ungültig", "jevKeyStatusChecking": "API-Schlüssel wird geprüft…", "jevKeyStatusUnavailable": "API-Schlüsselstatus konnte nicht geprüft werden", "jevConfigureKey": "Schlüssel hinzufügen", @@ -1655,10 +1661,10 @@ sklm: { "jevRemoveKey": "Schlüssel entfernen", "jevTestAndSave": "Prüfen und speichern", "jevCheckingKey": "Schlüssel wird geprüft…", - "jevKeyRequired": "Bitte zuerst einen TypeSafe-API-Schlüssel eingeben.", - "jevKeyCheckFailed": "TypeSafe hat diesen Schlüssel nicht akzeptiert.", - "jevServiceAdded": "TypeSafe-Schlüssel gespeichert, Jev ist aktiv", - "jevAddEnableNote": "„Prüfen und speichern“ fragt TypeSafe, ob der Schlüssel funktioniert, und aktiviert danach Jev für den Agent-Modus.", + "jevKeyRequired": "Bitte zuerst einen System One-API-Schlüssel eingeben.", + "jevKeyCheckFailed": "Der konfigurierte System One-Endpunkt hat diesen Schlüssel nicht akzeptiert.", + "jevServiceAdded": "System One-Endpunkt gespeichert, Jev ist aktiv", + "jevAddEnableNote": "„Prüfen und speichern“ prüft den konfigurierten System One-Endpunkt, speichert den Schlüssel sicher und aktiviert danach Jev für den Agent-Modus.", "jevKeyRemoved": "Jev-API-Schlüssel entfernt", "catalogNeverFetched": "nie", "serviceModels": "Modelle aus diesem Dienst", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index e1c607da43..bd697bdb73 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1538,6 +1538,7 @@ sklm: { apiStyle: "API format", apiStyleChatCompletions: "OpenAI Chat Completions", apiStyleOpenCodeGo: "OpenCode Go", + apiStyleSystemOne: "System One (classifier)", service: "Service", searchService: "Filter services", noServiceMatches: "No matching services.", @@ -1660,14 +1661,19 @@ sklm: { catalogStatusLine: "Catalog: {{source}} · {{models}} models · updated {{fetchedAt}}", catalogStatusUnknown: "Model catalog status unavailable", jevTitle: "Jev structured classifier", - jevDescription: "Configure TypeSafe Jev, a structured classifier that can answer choice, score, and boolean questions for the Agent.", + jevOfficialService: "TypeSafe (official)", + jevDescription: "Configure a System One endpoint for Jev, a structured classifier that answers choice, score, and boolean questions for the Agent.", + jevBaseUrl: "System One Base URL", + jevModelId: "Model ID", jevEnable: "Enable Jev for Agent", jevEnableDescription: "Expose Jev as an on-demand tool in Agent mode. It is unavailable in Plan and Goal modes.", - jevPrivacyNotice: "When the Agent calls JevClassify, the state and questions passed to it are sent directly to TypeSafe. Do not include secrets or personal information.", - jevApiKey: "TypeSafe API key", - jevApiKeyPlaceholder: "Paste TypeSafe API key", + jevClassifierOnlyNote: "This classifier powers JevClassify only. It will not appear in chat, default-model, or subagent model lists.", + jevPrivacyNotice: "When the Agent calls JevClassify, the state and questions passed to it are sent directly to the configured System One endpoint. Do not include secrets or personal information.", + jevApiKey: "System One API key", + jevApiKeyPlaceholder: "Paste the System One API key", jevKeyConfigured: "API key saved securely", jevKeyMissing: "API key not configured", + jevBindingInvalid: "Classifier configuration is invalid", jevKeyStatusChecking: "Checking API key…", jevKeyStatusUnavailable: "Could not check API key status", jevConfigureKey: "Add key", @@ -1675,10 +1681,10 @@ sklm: { jevRemoveKey: "Remove key", jevTestAndSave: "Check and save", jevCheckingKey: "Checking key…", - jevKeyRequired: "Enter a TypeSafe API key first.", - jevKeyCheckFailed: "TypeSafe did not accept this key.", - jevServiceAdded: "TypeSafe key saved; Jev is on", - jevAddEnableNote: "Check and save asks TypeSafe whether the key works, then turns Jev on for Agent mode.", + jevKeyRequired: "Enter a System One API key first.", + jevKeyCheckFailed: "The configured System One endpoint did not accept this key.", + jevServiceAdded: "System One endpoint saved; Jev is on", + jevAddEnableNote: "Check and save probes the configured System One endpoint, then saves the key securely and turns Jev on for Agent mode.", jevKeyRemoved: "Jev API key removed", catalogNeverFetched: "never", serviceModels: "Models from this service", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index b27866b952..07042d3d21 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1521,6 +1521,7 @@ sklm: { "apiStyle": "Formato API", "apiStyleChatCompletions": "Finalizaciones de OpenAI Chat", "apiStyleOpenCodeGo": "OpenCode Go", + "apiStyleSystemOne": "System One (clasificador)", "service": "Servicio", "searchService": "Filtrar servicios", "noServiceMatches": "No hay servicios coincidentes.", @@ -1640,14 +1641,19 @@ sklm: { "catalogStatusLine": "Catálogo: {{source}} · {{models}} modelos · actualizado {{fetchedAt}}", "catalogStatusUnknown": "Estado del catálogo de modelos no disponible", "jevTitle": "Clasificador estructurado Jev", - "jevDescription": "Configura TypeSafe Jev, un clasificador que responde preguntas estructuradas de opción, puntuación y verdadero o falso para el agente.", + "jevOfficialService": "TypeSafe (oficial)", + "jevDescription": "Configura un endpoint de System One para Jev, un clasificador estructurado que responde preguntas de opción, puntuación y verdadero o falso para el agente.", + "jevBaseUrl": "URL base de System One", + "jevModelId": "ID del modelo", "jevEnable": "Activar Jev para el agente", "jevEnableDescription": "Jev estará disponible como herramienta bajo demanda en el modo Agent. No está disponible en Plan y Goal.", - "jevPrivacyNotice": "Al llamar a JevClassify, el estado y las preguntas que se le pasen se envían directamente a TypeSafe. No incluyas secretos ni datos personales.", - "jevApiKey": "Clave API de TypeSafe", - "jevApiKeyPlaceholder": "Pega la clave API de TypeSafe", + "jevClassifierOnlyNote": "Este clasificador solo se usa con JevClassify. No aparecerá en las listas de modelos del chat, predeterminados ni de subagentes.", + "jevPrivacyNotice": "Al llamar a JevClassify, el estado y las preguntas se envían directamente al endpoint de System One configurado. No incluyas secretos ni datos personales.", + "jevApiKey": "Clave API de System One", + "jevApiKeyPlaceholder": "Pega la clave API de System One", "jevKeyConfigured": "Clave API guardada de forma segura", "jevKeyMissing": "No hay una clave API configurada", + "jevBindingInvalid": "La configuración del clasificador no es válida", "jevKeyStatusChecking": "Comprobando la clave API…", "jevKeyStatusUnavailable": "No se pudo comprobar el estado de la clave API", "jevConfigureKey": "Añadir clave", @@ -1655,10 +1661,10 @@ sklm: { "jevRemoveKey": "Eliminar clave", "jevTestAndSave": "Comprobar y guardar", "jevCheckingKey": "Comprobando la clave…", - "jevKeyRequired": "Introduce primero una clave API de TypeSafe.", - "jevKeyCheckFailed": "TypeSafe no aceptó esta clave.", - "jevServiceAdded": "Clave de TypeSafe guardada; Jev está activado", - "jevAddEnableNote": "Comprobar y guardar pregunta a TypeSafe si la clave funciona y después activa Jev para el modo Agent.", + "jevKeyRequired": "Introduce primero una clave API de System One.", + "jevKeyCheckFailed": "El endpoint de System One configurado no aceptó esta clave.", + "jevServiceAdded": "Endpoint de System One guardado; Jev está activado", + "jevAddEnableNote": "Comprobar y guardar prueba el endpoint de System One configurado, guarda la clave de forma segura y después activa Jev para el modo Agent.", "jevKeyRemoved": "Clave API de Jev eliminada", "catalogNeverFetched": "nunca", "serviceModels": "Modelos de este servicio", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 00a2ec63e8..2ac02070be 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1521,6 +1521,7 @@ sklm: { "apiStyle": "Format API", "apiStyleChatCompletions": "Achèvements de discussion OpenAI", "apiStyleOpenCodeGo": "OpenCode Go", + "apiStyleSystemOne": "System One (classificateur)", "service": "Service", "searchService": "Filtrer les services", "noServiceMatches": "Aucun service correspondant.", @@ -1640,14 +1641,19 @@ sklm: { "catalogStatusLine": "Catalogue : {{source}} · {{models}} modèles · mis à jour {{fetchedAt}}", "catalogStatusUnknown": "État du catalogue de modèles indisponible", "jevTitle": "Classificateur structuré Jev", - "jevDescription": "Configurez TypeSafe Jev, un classificateur structuré qui répond aux choix, scores et questions booléennes de l’Agent.", + "jevOfficialService": "TypeSafe (officiel)", + "jevDescription": "Configurez un point de terminaison System One pour Jev, un classificateur structuré qui répond aux choix, scores et questions booléennes de l’Agent.", + "jevBaseUrl": "URL de base System One", + "jevModelId": "ID du modèle", "jevEnable": "Activer Jev pour l’Agent", "jevEnableDescription": "Jev devient un outil à la demande en mode Agent. Il n’est pas disponible en modes Plan et Goal.", - "jevPrivacyNotice": "Lors d’un appel à JevClassify, l’état et les questions fournis sont envoyés directement à TypeSafe. N’incluez ni secrets ni données personnelles.", - "jevApiKey": "Clé API TypeSafe", - "jevApiKeyPlaceholder": "Collez la clé API TypeSafe", + "jevClassifierOnlyNote": "Ce classificateur sert uniquement à JevClassify. Il n’apparaît pas dans les listes de modèles de chat, par défaut ou de sous-agents.", + "jevPrivacyNotice": "Lors d’un appel à JevClassify, l’état et les questions sont envoyés directement au point de terminaison System One configuré. N’incluez ni secrets ni données personnelles.", + "jevApiKey": "Clé API System One", + "jevApiKeyPlaceholder": "Collez la clé API System One", "jevKeyConfigured": "Clé API enregistrée de façon sécurisée", "jevKeyMissing": "Aucune clé API configurée", + "jevBindingInvalid": "La configuration du classificateur est invalide", "jevKeyStatusChecking": "Vérification de la clé API…", "jevKeyStatusUnavailable": "Impossible de vérifier l’état de la clé API", "jevConfigureKey": "Ajouter une clé", @@ -1655,10 +1661,10 @@ sklm: { "jevRemoveKey": "Supprimer la clé", "jevTestAndSave": "Vérifier et enregistrer", "jevCheckingKey": "Vérification de la clé…", - "jevKeyRequired": "Saisissez d’abord une clé API TypeSafe.", - "jevKeyCheckFailed": "TypeSafe n’a pas accepté cette clé.", - "jevServiceAdded": "Clé TypeSafe enregistrée ; Jev est activé", - "jevAddEnableNote": "Vérifier et enregistrer demande à TypeSafe si la clé fonctionne, puis active Jev en mode Agent.", + "jevKeyRequired": "Saisissez d’abord une clé API System One.", + "jevKeyCheckFailed": "Le point de terminaison System One configuré n’a pas accepté cette clé.", + "jevServiceAdded": "Point de terminaison System One enregistré ; Jev est activé", + "jevAddEnableNote": "Vérifier et enregistrer teste le point de terminaison System One configuré, enregistre la clé de façon sécurisée, puis active Jev en mode Agent.", "jevKeyRemoved": "Clé API Jev supprimée", "catalogNeverFetched": "jamais", "serviceModels": "Modèles de ce service", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 0c9e1fa25e..1cf6076d84 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1534,6 +1534,7 @@ sklm: { apiStyle: "API 형식", apiStyleChatCompletions: "OpenAI Chat Completions", apiStyleOpenCodeGo: "OpenCode Go", + apiStyleSystemOne: "System One(분류기)", service: "서비스", searchService: "서비스 필터", noServiceMatches: "일치하는 서비스가 없습니다.", @@ -1656,14 +1657,19 @@ sklm: { catalogStatusLine: "카탈로그: {{source}} · 모델 {{models}}개 · {{fetchedAt}} 업데이트됨", catalogStatusUnknown: "모델 카탈로그 상태를 확인할 수 없음", jevTitle: "Jev 구조화 분류기", - jevDescription: "Agent의 선택, 점수, 참/거짓 질문에 답하는 구조화 분류기 TypeSafe Jev를 설정합니다.", + jevOfficialService: "TypeSafe(공식)", + jevDescription: "Agent의 선택형, 점수형, 참/거짓 질문에 답하는 Jev 구조화 분류기에 사용할 System One 엔드포인트를 설정합니다.", + jevBaseUrl: "System One 기본 URL", + jevModelId: "모델 ID", jevEnable: "Agent에서 Jev 사용", jevEnableDescription: "Jev가 Agent 모드에서 필요할 때 호출하는 도구로 제공됩니다. Plan 및 Goal 모드에서는 사용할 수 없습니다.", - jevPrivacyNotice: "Agent가 JevClassify를 호출하면 전달된 상태와 질문이 TypeSafe로 직접 전송됩니다. 비밀이나 개인 정보를 포함하지 마세요.", - jevApiKey: "TypeSafe API 키", - jevApiKeyPlaceholder: "TypeSafe API 키 붙여넣기", + jevClassifierOnlyNote: "이 분류기는 JevClassify 전용입니다. 채팅, 기본 모델 또는 서브에이전트 모델 목록에는 표시되지 않습니다.", + jevPrivacyNotice: "Agent가 JevClassify를 호출하면 전달된 상태와 질문이 설정된 System One 엔드포인트로 직접 전송됩니다. 비밀이나 개인 정보를 포함하지 마세요.", + jevApiKey: "System One API 키", + jevApiKeyPlaceholder: "System One API 키 붙여넣기", jevKeyConfigured: "API 키가 안전하게 저장됨", jevKeyMissing: "API 키가 설정되지 않음", + jevBindingInvalid: "분류기 구성이 올바르지 않음", jevKeyStatusChecking: "API 키 확인 중…", jevKeyStatusUnavailable: "API 키 상태를 확인할 수 없음", jevConfigureKey: "키 추가", @@ -1671,10 +1677,10 @@ sklm: { jevRemoveKey: "키 삭제", jevTestAndSave: "확인 후 저장", jevCheckingKey: "키 확인 중…", - jevKeyRequired: "먼저 TypeSafe API 키를 입력하세요.", - jevKeyCheckFailed: "TypeSafe가 이 키를 거부했습니다.", - jevServiceAdded: "TypeSafe 키를 저장했고 Jev를 켰습니다", - jevAddEnableNote: "확인 후 저장은 TypeSafe에 키가 작동하는지 물은 뒤 Agent 모드에서 Jev를 켭니다.", + jevKeyRequired: "먼저 System One API 키를 입력하세요.", + jevKeyCheckFailed: "설정된 System One 엔드포인트가 이 키를 거부했습니다.", + jevServiceAdded: "System One 엔드포인트가 저장되었고 Jev가 켜졌습니다", + jevAddEnableNote: "확인 후 저장은 설정된 System One 엔드포인트를 테스트하고 키를 안전하게 저장한 뒤 Agent 모드에서 Jev를 켭니다.", jevKeyRemoved: "Jev API 키 삭제됨", catalogNeverFetched: "없음", serviceModels: "이 서비스의 모델", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 0c8e52c291..63efc0ba8c 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -1491,6 +1491,7 @@ export const ptBR = { apiStyle: "Formato API", apiStyleChatCompletions: "OpenAI Chat Completions", apiStyleOpenCodeGo: "OpenCode Go", + apiStyleSystemOne: "System One (classificador)", service: "Serviço", searchService: "Filtrar serviços", noServiceMatches: "Nenhum serviço correspondente.", @@ -1612,14 +1613,19 @@ export const ptBR = { catalogStatusLine: "Catálogo: {{source}} · {{models}} modelos · atualizado {{fetchedAt}}", catalogStatusUnknown: "Status do catálogo de modelos indisponível", jevTitle: "Classificador estruturado Jev", - jevDescription: "Configure o TypeSafe Jev, um classificador estruturado que responde perguntas de escolha, pontuação e sim/não para o Agent.", + jevOfficialService: "TypeSafe (oficial)", + jevDescription: "Configure um endpoint do System One para o Jev, um classificador estruturado que responde perguntas de escolha, pontuação e sim/não para o Agent.", + jevBaseUrl: "URL base do System One", + jevModelId: "ID do modelo", jevEnable: "Ativar Jev para o Agent", jevEnableDescription: "O Jev fica disponível como ferramenta sob demanda no modo Agent. Não fica disponível nos modos Plan e Goal.", - jevPrivacyNotice: "Ao chamar JevClassify, o estado e as perguntas fornecidos são enviados diretamente ao TypeSafe. Não inclua segredos nem dados pessoais.", - jevApiKey: "Chave de API do TypeSafe", - jevApiKeyPlaceholder: "Cole a chave de API do TypeSafe", + jevClassifierOnlyNote: "Este classificador é usado apenas pelo JevClassify. Ele não aparecerá nas listas de modelos de chat, padrão ou de subagentes.", + jevPrivacyNotice: "Ao chamar JevClassify, o estado e as perguntas são enviados diretamente ao endpoint do System One configurado. Não inclua segredos nem dados pessoais.", + jevApiKey: "Chave de API do System One", + jevApiKeyPlaceholder: "Cole a chave de API do System One", jevKeyConfigured: "Chave de API salva com segurança", jevKeyMissing: "Nenhuma chave de API configurada", + jevBindingInvalid: "A configuração do classificador é inválida", jevKeyStatusChecking: "Verificando a chave de API…", jevKeyStatusUnavailable: "Não foi possível verificar o status da chave de API", jevConfigureKey: "Adicionar chave", @@ -1627,10 +1633,10 @@ export const ptBR = { jevRemoveKey: "Remover chave", jevTestAndSave: "Testar e salvar", jevCheckingKey: "Testando a chave…", - jevKeyRequired: "Informe primeiro uma chave de API do TypeSafe.", - jevKeyCheckFailed: "O TypeSafe não aceitou esta chave.", - jevServiceAdded: "Chave do TypeSafe salva; o Jev está ativado", - jevAddEnableNote: "Testar e salvar pergunta ao TypeSafe se a chave funciona e depois ativa o Jev no modo Agent.", + jevKeyRequired: "Informe primeiro uma chave de API do System One.", + jevKeyCheckFailed: "O endpoint do System One configurado não aceitou esta chave.", + jevServiceAdded: "Endpoint do System One salvo; o Jev está ativado", + jevAddEnableNote: "Testar e salvar testa o endpoint do System One configurado, salva a chave com segurança e depois ativa o Jev no modo Agent.", jevKeyRemoved: "Chave de API do Jev removida", catalogNeverFetched: "nunca", serviceModels: "Modelos deste serviço", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index a885b2d4bb..5060f58136 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1528,6 +1528,7 @@ sklm: { apiStyle: "API biçimi", apiStyleChatCompletions: "OpenAI Chat Completions", apiStyleOpenCodeGo: "OpenCode Go", + apiStyleSystemOne: "System One (sınıflandırıcı)", service: "Servis", searchService: "Servisleri süz", noServiceMatches: "Eşleşen servis yok.", @@ -1650,14 +1651,19 @@ sklm: { catalogStatusLine: "Katalog: {{source}} · {{models}} model · güncellendi {{fetchedAt}}", catalogStatusUnknown: "Model katalog durumu yok", jevTitle: "Jev yapılandırılmış sınıflandırıcı", - jevDescription: "Agent için seçenek, puan ve doğru/yanlış sorularını yanıtlayan yapılandırılmış sınıflandırıcı TypeSafe Jev'i ayarlayın.", + jevOfficialService: "TypeSafe (resmî)", + jevDescription: "Agent'ın seçim, puanlama ve doğru/yanlış sorularını yanıtlayan yapılandırılmış Jev sınıflandırıcısı için bir System One uç noktası ayarlayın.", + jevBaseUrl: "System One temel URL'si", + jevModelId: "Model kimliği", jevEnable: "Agent için Jev'i etkinleştir", jevEnableDescription: "Jev, Agent modunda isteğe bağlı bir araç olur. Plan ve Goal modlarında kullanılamaz.", - jevPrivacyNotice: "Agent JevClassify'ı çağırdığında sağlanan durum ve sorular doğrudan TypeSafe'e gönderilir. Gizli veya kişisel bilgi eklemeyin.", - jevApiKey: "TypeSafe API anahtarı", - jevApiKeyPlaceholder: "TypeSafe API anahtarını yapıştırın", + jevClassifierOnlyNote: "Bu sınıflandırıcı yalnızca JevClassify'ı çalıştırır. Sohbet, varsayılan model veya alt ajan model listelerinde görünmez.", + jevPrivacyNotice: "Agent JevClassify'ı çağırdığında sağlanan durum ve sorular doğrudan yapılandırılmış System One uç noktasına gönderilir. Gizli veya kişisel bilgi eklemeyin.", + jevApiKey: "System One API anahtarı", + jevApiKeyPlaceholder: "System One API anahtarını yapıştırın", jevKeyConfigured: "API anahtarı güvenli biçimde kaydedildi", jevKeyMissing: "API anahtarı yapılandırılmadı", + jevBindingInvalid: "Sınıflandırıcı yapılandırması geçersiz", jevKeyStatusChecking: "API anahtarı denetleniyor…", jevKeyStatusUnavailable: "API anahtarı durumu denetlenemedi", jevConfigureKey: "Anahtar ekle", @@ -1665,10 +1671,10 @@ sklm: { jevRemoveKey: "Anahtarı kaldır", jevTestAndSave: "Denetle ve kaydet", jevCheckingKey: "Anahtar denetleniyor…", - jevKeyRequired: "Önce bir TypeSafe API anahtarı girin.", - jevKeyCheckFailed: "TypeSafe bu anahtarı kabul etmedi.", - jevServiceAdded: "TypeSafe anahtarı kaydedildi; Jev açık", - jevAddEnableNote: "Denetle ve kaydet, anahtarın çalışıp çalışmadığını TypeSafe'e sorar ve ardından Agent modunda Jev'i açar.", + jevKeyRequired: "Önce bir System One API anahtarı girin.", + jevKeyCheckFailed: "Yapılandırılmış System One uç noktası bu anahtarı kabul etmedi.", + jevServiceAdded: "System One uç noktası kaydedildi; Jev açık", + jevAddEnableNote: "Denetle ve kaydet, yapılandırılmış System One uç noktasını sınar, ardından anahtarı güvenli biçimde kaydedip Agent modunda Jev'i açar.", jevKeyRemoved: "Jev API anahtarı kaldırıldı", catalogNeverFetched: "hiç", serviceModels: "Bu servisin modelleri", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index a89ab066e2..46d2959e06 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1516,6 +1516,7 @@ sklm: { apiStyle: "接口格式", apiStyleChatCompletions: "OpenAI Chat Completions", apiStyleOpenCodeGo: "OpenCode Go", + apiStyleSystemOne: "System One(分类器)", service: "服务", searchService: "筛选服务", noServiceMatches: "没有匹配的服务。", @@ -1635,14 +1636,19 @@ sklm: { catalogStatusLine: "目录:{{source}} · {{models}} 个模型 · 更新于 {{fetchedAt}}", catalogStatusUnknown: "模型目录状态不可用", jevTitle: "Jev 结构化分类器", - jevDescription: "配置 TypeSafe Jev。它能为 Agent 回答选项、评分和是非类结构化问题。", + jevOfficialService: "TypeSafe(官方)", + jevDescription: "配置用于 Jev 的 System One 端点。Jev 是供 Agent 回答选项、评分和是非类问题的结构化分类器。", + jevBaseUrl: "System One 基础 URL", + jevModelId: "模型 ID", jevEnable: "在 Agent 中启用 Jev", jevEnableDescription: "启用后,Jev 会作为 Agent 模式下的按需工具;Plan 和 Goal 模式不可用。", - jevPrivacyNotice: "Agent 调用 JevClassify 时,传入的状态和问题会直接发送给 TypeSafe。请勿包含密钥或个人信息。", - jevApiKey: "TypeSafe API 密钥", - jevApiKeyPlaceholder: "粘贴 TypeSafe API 密钥", + jevClassifierOnlyNote: "此分类器仅供 JevClassify 使用,不会出现在聊天、默认模型或子智能体模型列表中。", + jevPrivacyNotice: "Agent 调用 JevClassify 时,传入的状态和问题会直接发送到已配置的 System One 端点。请勿包含密钥或个人信息。", + jevApiKey: "System One API 密钥", + jevApiKeyPlaceholder: "粘贴 System One API 密钥", jevKeyConfigured: "API 密钥已安全保存", jevKeyMissing: "尚未配置 API 密钥", + jevBindingInvalid: "分类器配置无效", jevKeyStatusChecking: "正在检查 API 密钥…", jevKeyStatusUnavailable: "无法检查 API 密钥状态", jevConfigureKey: "配置密钥", @@ -1650,10 +1656,10 @@ sklm: { jevRemoveKey: "移除密钥", jevTestAndSave: "测试并保存", jevCheckingKey: "正在测试密钥…", - jevKeyRequired: "请先填写 TypeSafe API 密钥。", - jevKeyCheckFailed: "TypeSafe 未接受该密钥。", - jevServiceAdded: "TypeSafe 密钥已保存,Jev 已启用", - jevAddEnableNote: "保存时会先向 TypeSafe 测试此密钥,通过后为 Agent 模式启用 Jev。", + jevKeyRequired: "请先填写 System One API 密钥。", + jevKeyCheckFailed: "已配置的 System One 端点未接受该密钥。", + jevServiceAdded: "System One 端点已保存,Jev 已启用", + jevAddEnableNote: "“测试并保存”会先探测已配置的 System One 端点,再安全保存密钥并为 Agent 模式启用 Jev。", jevKeyRemoved: "Jev API 密钥已移除", catalogNeverFetched: "从未获取", serviceModels: "该服务的模型", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 93b9aae2d8..279f96702f 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1516,6 +1516,7 @@ sklm: { apiStyle: "介面格式", apiStyleChatCompletions: "OpenAI Chat Completions", apiStyleOpenCodeGo: "OpenCode Go", + apiStyleSystemOne: "System One(分類器)", service: "服務", searchService: "篩選服務", noServiceMatches: "沒有匹配的服務。", @@ -1635,14 +1636,19 @@ sklm: { catalogStatusLine: "目錄:{{source}} · {{models}} 個模型 · 更新於 {{fetchedAt}}", catalogStatusUnknown: "模型目錄狀態不可用", jevTitle: "Jev 結構化分類器", - jevDescription: "設定 TypeSafe Jev。它能為 Agent 回答選擇、評分和是非類結構化問題。", + jevOfficialService: "TypeSafe(官方)", + jevDescription: "設定供 Jev 使用的 System One 端點。Jev 是為 Agent 回答選擇、評分和是非類問題的結構化分類器。", + jevBaseUrl: "System One 基礎 URL", + jevModelId: "模型 ID", jevEnable: "在 Agent 中啟用 Jev", jevEnableDescription: "啟用後,Jev 會作為 Agent 模式中的隨選工具;Plan 和 Goal 模式無法使用。", - jevPrivacyNotice: "Agent 呼叫 JevClassify 時,傳入的狀態和問題會直接傳送給 TypeSafe。請勿包含密鑰或個人資訊。", - jevApiKey: "TypeSafe API 金鑰", - jevApiKeyPlaceholder: "貼上 TypeSafe API 金鑰", + jevClassifierOnlyNote: "此分類器僅供 JevClassify 使用,不會出現在聊天、預設模型或子智慧體模型清單中。", + jevPrivacyNotice: "Agent 呼叫 JevClassify 時,傳入的狀態和問題會直接傳送到已設定的 System One 端點。請勿包含密鑰或個人資訊。", + jevApiKey: "System One API 金鑰", + jevApiKeyPlaceholder: "貼上 System One API 金鑰", jevKeyConfigured: "API 金鑰已安全儲存", jevKeyMissing: "尚未設定 API 金鑰", + jevBindingInvalid: "分類器設定無效", jevKeyStatusChecking: "正在檢查 API 金鑰…", jevKeyStatusUnavailable: "無法檢查 API 金鑰狀態", jevConfigureKey: "設定金鑰", @@ -1650,10 +1656,10 @@ sklm: { jevRemoveKey: "移除金鑰", jevTestAndSave: "測試並儲存", jevCheckingKey: "正在測試金鑰…", - jevKeyRequired: "請先填寫 TypeSafe API 金鑰。", - jevKeyCheckFailed: "TypeSafe 未接受此金鑰。", - jevServiceAdded: "TypeSafe 金鑰已儲存,Jev 已啟用", - jevAddEnableNote: "儲存時會先向 TypeSafe 測試此金鑰,通過後為 Agent 模式啟用 Jev。", + jevKeyRequired: "請先填寫 System One API 金鑰。", + jevKeyCheckFailed: "已設定的 System One 端點未接受此金鑰。", + jevServiceAdded: "System One 端點已儲存,Jev 已啟用", + jevAddEnableNote: "「測試並儲存」會先探測已設定的 System One 端點,再安全儲存金鑰並為 Agent 模式啟用 Jev。", jevKeyRemoved: "Jev API 金鑰已移除", catalogNeverFetched: "從未獲取", serviceModels: "該服務的模型", diff --git a/packages/shared/src/changelog-de.ts b/packages/shared/src/changelog-de.ts index 7168a33a99..e8379d574f 100644 --- a/packages/shared/src/changelog-de.ts +++ b/packages/shared/src/changelog-de.ts @@ -5,6 +5,7 @@ export const deEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "Jev lässt sich mit einem eigenen System-One-Endpunkt und Modell verbinden, ohne den Klassifikator in Chat-Modelllisten aufzunehmen.", "Installierte Plugins erscheinen als Karten in einem selbst füllenden Raster, jede mit einer klaren Aktion und sichtbarem Vertrauens-, Update- und Geltungsstatus.", "Öffentliche Bewertungen und Rezensionen für Marketplace-Plugins lesen und im Plugin-Center eine schreiben — die Desktop-App meldet sich nie an.", "Lange Bash-Befehle laufen nach dem Agenten-Turn als Hintergrundaufgaben weiter; ihre Ausgabe lässt sich später prüfen.", diff --git a/packages/shared/src/changelog-en.ts b/packages/shared/src/changelog-en.ts index 88069b4a2d..74338fc810 100644 --- a/packages/shared/src/changelog-en.ts +++ b/packages/shared/src/changelog-en.ts @@ -5,6 +5,7 @@ export const enEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "Connect Jev to a custom System One endpoint and model without adding the classifier to chat model lists.", "Browse installed plugins as cards in an auto-filling grid, each with one clear action and its trust, update, and scope state at a glance.", "Read public ratings and reviews for marketplace plugins, and open the plugin center to write one — the desktop never signs in.", "Keep long-running Bash commands going after a turn ends, then review their output as background tasks.", diff --git a/packages/shared/src/changelog-es.ts b/packages/shared/src/changelog-es.ts index 5eaa2b87df..c004982a5b 100644 --- a/packages/shared/src/changelog-es.ts +++ b/packages/shared/src/changelog-es.ts @@ -5,6 +5,7 @@ export const esEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "Conecta Jev a un endpoint y modelo personalizados de System One sin añadir el clasificador a las listas de modelos de chat.", "Los plugins instalados aparecen como tarjetas en una cuadrícula autoajustable, cada una con una acción clara y su estado de confianza, actualización y alcance a la vista.", "Lee valoraciones y reseñas públicas de los plugins del mercado y abre el centro de plugins para escribir una: la aplicación de escritorio nunca inicia sesión.", "Los comandos Bash largos continúan como tareas en segundo plano cuando termina el turno, y puedes consultar su salida después.", diff --git a/packages/shared/src/changelog-fr.ts b/packages/shared/src/changelog-fr.ts index 69eac6537e..2f20f7f6bd 100644 --- a/packages/shared/src/changelog-fr.ts +++ b/packages/shared/src/changelog-fr.ts @@ -5,6 +5,7 @@ export const frEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "Connectez Jev à un point de terminaison et un modèle System One personnalisés sans ajouter le classificateur aux listes de modèles de chat.", "Les plugins installés s'affichent en cartes dans une grille auto-adaptative, chacune avec une action claire et son état de confiance, de mise à jour et de portée visible.", "Consultez les notes et avis publics des plugins du marché et ouvrez le centre de plugins pour en écrire un — l'application de bureau ne se connecte jamais.", "Les longues commandes Bash continuent en tâche de fond à la fin du tour, et leur sortie reste consultable.", diff --git a/packages/shared/src/changelog-ko.ts b/packages/shared/src/changelog-ko.ts index 9f1f3395e1..a222fe68b0 100644 --- a/packages/shared/src/changelog-ko.ts +++ b/packages/shared/src/changelog-ko.ts @@ -5,6 +5,7 @@ export const koEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "Jev를 사용자 지정 System One 엔드포인트와 모델에 연결해도 분류기는 채팅 모델 목록에 추가되지 않습니다.", "설치된 플러그인이 자동 채움 카드 그리드로 표시되고, 각 카드에 명확한 동작과 신뢰·업데이트·적용 범위 상태가 함께 보입니다.", "마켓플레이스 플러그인의 공개 평점과 리뷰를 읽고, 플러그인 센터에서 리뷰를 작성할 수 있습니다. 데스크톱은 로그인하지 않습니다.", "긴 Bash 명령은 턴이 끝난 뒤에도 백그라운드 작업으로 계속 실행되며, 나중에 출력을 확인할 수 있습니다.", diff --git a/packages/shared/src/changelog-pt-BR.ts b/packages/shared/src/changelog-pt-BR.ts index 25d618a3ce..1c541d705c 100644 --- a/packages/shared/src/changelog-pt-BR.ts +++ b/packages/shared/src/changelog-pt-BR.ts @@ -5,6 +5,7 @@ export const ptBREntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "Conecte o Jev a um endpoint e modelo personalizados do System One sem adicionar o classificador às listas de modelos de chat.", "Os plugins instalados aparecem como cartões em uma grade de preenchimento automático, cada um com uma ação clara e o estado de confiança, atualização e escopo à vista.", "Leia notas e avaliações públicas dos plugins do mercado e abra o centro de plugins para escrever uma — o aplicativo de desktop nunca faz login.", "Comandos Bash longos continuam em tarefas em segundo plano após o turno, e você pode consultar a saída depois.", diff --git a/packages/shared/src/changelog-tr.ts b/packages/shared/src/changelog-tr.ts index c1c5ef7061..fb7ab65416 100644 --- a/packages/shared/src/changelog-tr.ts +++ b/packages/shared/src/changelog-tr.ts @@ -5,6 +5,7 @@ export const trEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "Jev'i özel bir System One uç noktasına ve modele bağlayın; sınıflandırıcı sohbet modeli listelerine eklenmez.", "Yüklü eklentiler kendiliğinden dolan bir kart ızgarasında görünür; her kartta net bir eylem ile güven, güncelleme ve kapsam durumu yer alır.", "Mağaza eklentilerinin herkese açık puanlarını ve değerlendirmelerini okuyun, yazmak için eklenti merkezini açın — masaüstü uygulaması hiçbir zaman oturum açmaz.", "Uzun Bash komutları tur bittikten sonra arka plan görevleri olarak çalışmaya devam eder; çıktıları daha sonra inceleyebilirsiniz.", diff --git a/packages/shared/src/changelog-zh-CN.ts b/packages/shared/src/changelog-zh-CN.ts index 224d77997c..e8a5b89ba7 100644 --- a/packages/shared/src/changelog-zh-CN.ts +++ b/packages/shared/src/changelog-zh-CN.ts @@ -5,6 +5,7 @@ export const zhCNEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "可将 Jev 连接到自定义 System One 端点和模型,且分类器不会加入聊天模型列表。", "已安装插件改为自适应卡片网格,每张卡片都有明确的主操作,信任、更新与作用范围一目了然。", "可查看插件市场的公开评分与评论,并可前往插件中心发表评论——桌面端无需登录。", "长时间运行的 Bash 命令可转为后台任务,在本轮结束后继续执行,并可随时查看输出。", diff --git a/packages/shared/src/changelog-zh-TW.ts b/packages/shared/src/changelog-zh-TW.ts index 53e5c04ba6..ec17a0db36 100644 --- a/packages/shared/src/changelog-zh-TW.ts +++ b/packages/shared/src/changelog-zh-TW.ts @@ -5,6 +5,7 @@ export const zhTWEntries: ChangelogEntry[] = [ version: "0.21.0", date: "2026-10-16", highlights: [ + "可將 Jev 連接到自訂 System One 端點與模型,且分類器不會加入聊天模型清單。", "已安裝外掛改為自適應卡片網格,每張卡片都有明確的主要操作,信任、更新與作用範圍一目了然。", "可查看外掛市場的公開評分與評論,並可前往外掛中心發表評論——桌面端無需登入。", "長時間執行的 Bash 命令可轉為背景工作,在本輪結束後繼續執行,並可隨時查看輸出。", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9ad014dc88..62f314b919 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -72,6 +72,7 @@ export * from "./native-web-search.js"; export * from "./native-web-search-transport.js"; export * from "./header-value.js"; export * from "./secret-refs.js"; +export * from "./jev.js"; export * from "./session-todos.js"; export * from "./tool-call-lineage.js"; diff --git a/packages/shared/src/jev.test.ts b/packages/shared/src/jev.test.ts new file mode 100644 index 0000000000..f054a34718 --- /dev/null +++ b/packages/shared/src/jev.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_JEV_CLASSIFIER_BINDING, + isDefaultJevClassifierBinding, + jevSystemOneUrl, + normalizeJevBaseUrl, + normalizeJevClassifierBinding, + normalizeJevModelId, + resolveJevClassifierBinding, +} from "./jev.js"; + +describe("Jev classifier binding", () => { + it("keeps the official TypeSafe binding as the legacy default", () => { + const resolved = resolveJevClassifierBinding(undefined); + expect(resolved).toEqual({ + status: "default", + binding: { + baseUrl: "https://api.typesafe.ai/v1", + modelId: "jev-latest", + }, + }); + if (resolved.status !== "invalid") { + expect(isDefaultJevClassifierBinding(resolved.binding)).toBe(true); + expect(jevSystemOneUrl(resolved.binding)).toBe( + "https://api.typesafe.ai/v1/systemone", + ); + } + }); + + it("accepts an API root or full System One operation without doubling the path", () => { + expect(normalizeJevBaseUrl("https://openrouter.ai/api/v1/")).toBe( + "https://openrouter.ai/api/v1", + ); + expect( + normalizeJevBaseUrl("https://example.test/v1/systemone/"), + ).toBe("https://example.test/v1"); + expect(normalizeJevBaseUrl("localhost:8787/v1/systemone")).toBe( + "https://localhost:8787/v1", + ); + expect( + jevSystemOneUrl({ + baseUrl: "https://openrouter.ai/api/v1", + modelId: "~typesafe/jev-latest", + }), + ).toBe("https://openrouter.ai/api/v1/systemone"); + }); + + it("rejects non-HTTP addresses and URLs that carry credentials or extra data", () => { + for (const value of [ + "", + "file:///tmp/systemone", + "https://user:secret@example.test/v1", + "https://example.test/v1?token=secret", + "https://example.test/v1#fragment", + ]) { + expect(normalizeJevBaseUrl(value), value).toBeUndefined(); + } + }); + + it("preserves exact safe classifier ids and rejects blank or unsafe ids", () => { + expect(normalizeJevModelId(" ~typesafe/jev-latest ")).toBe( + "~typesafe/jev-latest", + ); + for (const value of ["", " ", "two ids", "bad\nmodel"]) { + expect(normalizeJevModelId(value), value).toBeUndefined(); + } + }); + + it("normalizes explicit bindings and never turns corrupt explicit data into the default", () => { + expect( + normalizeJevClassifierBinding({ + baseUrl: "https://openrouter.ai/api/v1/systemone", + modelId: " ~typesafe/jev-latest ", + }), + ).toEqual({ + baseUrl: "https://openrouter.ai/api/v1", + modelId: "~typesafe/jev-latest", + }); + expect(resolveJevClassifierBinding(null)).toEqual({ + status: "invalid", + error: "Jev classifier binding is invalid", + }); + expect(resolveJevClassifierBinding({ baseUrl: "bad", modelId: "" })).toEqual({ + status: "invalid", + error: "Jev classifier binding is invalid", + }); + expect( + resolveJevClassifierBinding({ + baseUrl: "https://openrouter.test/api/v1", + modelId: "~typesafe/jev-latest", + unexpected: true, + }), + ).toEqual({ + status: "invalid", + error: "Jev classifier binding is invalid", + }); + expect(DEFAULT_JEV_CLASSIFIER_BINDING).toEqual({ + baseUrl: "https://api.typesafe.ai/v1", + modelId: "jev-latest", + }); + }); +}); diff --git a/packages/shared/src/jev.ts b/packages/shared/src/jev.ts new file mode 100644 index 0000000000..ff696506bf --- /dev/null +++ b/packages/shared/src/jev.ts @@ -0,0 +1,145 @@ +/** + * Non-secret configuration for the optional Jev System One classifier. + * + * The credential remains in Host secure storage. This module owns the endpoint + * and model identity shared by Settings, Electron main, and agent-runtime so a + * probe and a real classification can never silently target different places. + */ +import { + TYPESAFE_JEV_MODEL_ID, + TYPESAFE_SYSTEM_ONE_BASE_URL, +} from "./secret-refs.js"; + +export const JEV_SYSTEM_ONE_API_STYLE = "typesafe_system_one" as const; +export const MAX_JEV_BASE_URL_LENGTH = 2_048; +export const MAX_JEV_MODEL_ID_LENGTH = 256; + +export type JevClassifierBinding = { + /** API root. The System One adapter appends `/systemone` exactly once. */ + baseUrl: string; + /** Exact classifier id sent in the System One request body. */ + modelId: string; +}; + +/** Ephemeral Main → sidecar configuration; the API key is never persisted here. */ +export type JevClassifierRuntimeConfig = JevClassifierBinding & { + apiKey: string; +}; + +export const DEFAULT_JEV_CLASSIFIER_BINDING: Readonly = + Object.freeze({ + baseUrl: TYPESAFE_SYSTEM_ONE_BASE_URL, + modelId: TYPESAFE_JEV_MODEL_ID, + }); + +export type JevClassifierBindingResolution = + | { status: "default"; binding: JevClassifierBinding } + | { status: "configured"; binding: JevClassifierBinding } + | { status: "invalid"; error: string }; + +function normalizedHttpUrl(value: string): URL | undefined { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_JEV_BASE_URL_LENGTH) return undefined; + if (/^https?:/iu.test(trimmed) && !/^https?:\/\//iu.test(trimmed)) { + return undefined; + } + const withScheme = /^[a-z][a-z\d+.-]*:\/\//iu.test(trimmed) + ? trimmed + : `https://${trimmed}`; + try { + const parsed = new URL(withScheme); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return undefined; + if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +/** + * Canonical API root accepted from the settings field. + * + * A pasted operation URL is convenient and unambiguous, so a final + * `/systemone` segment is removed. Other path segments are preserved exactly; + * only redundant trailing slashes are dropped. + */ +export function normalizeJevBaseUrl(value: string): string | undefined { + const parsed = normalizedHttpUrl(value); + if (!parsed) return undefined; + let pathname = parsed.pathname.replace(/\/+$/u, ""); + if (/\/systemone$/iu.test(pathname)) { + pathname = pathname.slice(0, -"/systemone".length).replace(/\/+$/u, ""); + } + parsed.pathname = pathname || "/"; + const normalized = parsed.toString().replace(/\/$/u, ""); + return normalized || `${parsed.protocol}//${parsed.host}`; +} + +/** Exact classifier id after safe input normalization. */ +export function normalizeJevModelId(value: string): string | undefined { + const modelId = value.trim(); + if ( + !modelId || + modelId.length > MAX_JEV_MODEL_ID_LENGTH || + /[\s\u0000-\u001f\u007f]/u.test(modelId) + ) { + return undefined; + } + return modelId; +} + +/** Validate and normalize an explicit persisted/user-entered binding. */ +export function normalizeJevClassifierBinding( + value: unknown, +): JevClassifierBinding | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + if ( + Object.keys(record).length !== 2 || + !("baseUrl" in record) || + !("modelId" in record) + ) { + return undefined; + } + if (typeof record.baseUrl !== "string" || typeof record.modelId !== "string") { + return undefined; + } + const baseUrl = normalizeJevBaseUrl(record.baseUrl); + const modelId = normalizeJevModelId(record.modelId); + return baseUrl && modelId ? { baseUrl, modelId } : undefined; +} + +/** + * Resolve persisted settings without hiding corruption. + * + * Only an absent field means the legacy official TypeSafe default. Once a field + * exists, invalid data is an error and must not fall back to another endpoint. + */ +export function resolveJevClassifierBinding( + value: unknown, +): JevClassifierBindingResolution { + if (value === undefined) { + return { + status: "default", + binding: { ...DEFAULT_JEV_CLASSIFIER_BINDING }, + }; + } + const binding = normalizeJevClassifierBinding(value); + return binding + ? { status: "configured", binding } + : { status: "invalid", error: "Jev classifier binding is invalid" }; +} + +/** Absolute operation URL used by both the probe and pi-ai transport. */ +export function jevSystemOneUrl(binding: JevClassifierBinding): string { + return new URL("systemone", `${binding.baseUrl.replace(/\/+$/u, "")}/`).toString(); +} + +export function isDefaultJevClassifierBinding(binding: JevClassifierBinding): boolean { + return ( + binding.baseUrl === DEFAULT_JEV_CLASSIFIER_BINDING.baseUrl && + binding.modelId === DEFAULT_JEV_CLASSIFIER_BINDING.modelId + ); +} diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 746db1c214..d1273100ce 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -269,11 +269,12 @@ export const IPC = { providersSetSecret: "pi-desktop/providers/setSecret", providersTest: "pi-desktop/providers/testConnection", /** - * Check a TypeSafe key before the Jev settings row keeps it. + * Check a configured System One key before Jev keeps it. * - * Jev is not a provider row, so `providersTest` cannot answer this: the - * key is checked by the same System One round trip the Agent's - * `JevClassify` tool makes, and only a key that answered is stored. + * Jev is not a chat provider row, so `providersTest` cannot answer this: + * the supplied endpoint/model receive the same minimal classifier round + * trip the Agent's `JevClassify` tool makes, and only an answered key is + * stored. */ jevTest: "pi-desktop/jev/test", providersListModels: "pi-desktop/providers/listModels", diff --git a/packages/shared/src/secret-refs.ts b/packages/shared/src/secret-refs.ts index 9f5bd0e2e8..f4efd1a931 100644 --- a/packages/shared/src/secret-refs.ts +++ b/packages/shared/src/secret-refs.ts @@ -5,13 +5,7 @@ */ export const JEV_API_KEY_SECRET_REF = "secret:app:typesafe-jev"; -/** - * The TypeSafe System One endpoint behind Jev, and the classifier it asks. - * - * The settings key check calls this address. The Agent's `JevClassify` tool - * reaches the same one through pi-ai's `typesafe` provider, whose published - * base URL resolves to it; if that address ever moves in pi-ai, this constant - * is what the check has to follow. - */ -export const TYPESAFE_SYSTEM_ONE_URL = "https://api.typesafe.ai/v1/systemone"; +/** Official TypeSafe API root and the System One operation behind Jev. */ +export const TYPESAFE_SYSTEM_ONE_BASE_URL = "https://api.typesafe.ai/v1"; +export const TYPESAFE_SYSTEM_ONE_URL = `${TYPESAFE_SYSTEM_ONE_BASE_URL}/systemone`; export const TYPESAFE_JEV_MODEL_ID = "jev-latest"; diff --git a/packages/shared/src/types/settings.ts b/packages/shared/src/types/settings.ts index 2bf997b9e3..e9e809bf91 100644 --- a/packages/shared/src/types/settings.ts +++ b/packages/shared/src/types/settings.ts @@ -11,15 +11,14 @@ import type { SpeechSettings } from "./speech.js"; import type { ThinkingLevel } from "./models.js"; import type { UpdatePreference } from "./platform.js"; import type { LiveVoiceSettings } from "./live-voice.js"; +import type { JevClassifierBinding } from "../jev.js"; export type ThemePreference = "system" | "light" | "dark" | `plugin:${string}`; /** - * What a TypeSafe key check answered. - * - * `ok` means the classifier answered this key at its own address. A refusal - * carries the HTTP `status` and `message` when TypeSafe sent one; `message` is - * provider text with the key already redacted. + * `ok` means the configured classifier endpoint answered this key. A refusal + * carries the HTTP `status` and `message` when the endpoint sent one; `message` + * is provider text with the key already redacted. */ export type JevKeyCheckResult = { ok: boolean; @@ -27,6 +26,11 @@ export type JevKeyCheckResult = { message?: string; }; +/** Input for the non-persisting System One credential check. */ +export type JevKeyCheckInput = { + apiKey: string; + binding: JevClassifierBinding; +}; /** * What closing the main window does on Windows/Linux. macOS keeps the native * Dock lifecycle and never consults this preference. @@ -57,8 +61,10 @@ export type AppSettings = { * Absent and false use the bounded ten-retry policy. */ infiniteProviderRetry?: boolean; - /** Allow Agent mode to call TypeSafe Jev for explicit structured classifications. */ + /** Allow Agent mode to call the configured Jev System One classifier. */ jevEnabled?: boolean; + /** Non-secret System One endpoint/model. Absent keeps the official legacy default. */ + jevClassifier?: JevClassifierBinding | null; /** Prevent idle system sleep while this desktop app runs; off when absent. */ keepAwakeWhileRunning?: boolean; /** Configured command shell for the agent Bash protocol tool. */ diff --git a/scripts/e2e-provider-api-style.mjs b/scripts/e2e-provider-api-style.mjs index 50257705ac..37e9fc7136 100644 --- a/scripts/e2e-provider-api-style.mjs +++ b/scripts/e2e-provider-api-style.mjs @@ -46,7 +46,7 @@ const { app, BrowserWindow } = require("electron"); const path = require("node:path"); app.setPath("userData", path.join(__dirname, "profile")); app.whenReady().then(async () => { - const window = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } }); + const window = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); window.webContents.on("console-message", (event) => console.error(event.message)); try { await window.loadFile(path.join(__dirname, "index.html")); diff --git a/scripts/e2e/provider-api-style.tsx b/scripts/e2e/provider-api-style.tsx index 5f2e72c236..84baf62dd6 100644 --- a/scripts/e2e/provider-api-style.tsx +++ b/scripts/e2e/provider-api-style.tsx @@ -8,9 +8,14 @@ import { ProviderSetupDialog, type ProviderSetupDialogProps } from "../../apps/d import { VendorAccountDialog, type VendorAccountForm } from "../../apps/desktop/src/components/settings/VendorAccountDialog"; import { defaultModelOptions, providerServesChatModels } from "../../apps/desktop/src/components/settings/default-model"; import { subagentModelChoices } from "../../apps/desktop/src/components/settings/subagent-models"; -import { API_STYLE_LABEL_KEYS, CUSTOM_PROVIDER_API_STYLES } from "../../apps/desktop/src/components/settings/provider-api-style"; -import { copyProviderConfiguration } from "../../apps/desktop/src/components/settings/provider-copy"; +import { + API_STYLE_LABEL_KEYS, + CUSTOM_PROVIDER_API_STYLES, + CUSTOM_PROVIDER_SETUP_API_STYLES, + PROVIDER_SETUP_API_STYLE_LABEL_KEYS, +} from "../../apps/desktop/src/components/settings/provider-api-style"; import { CUSTOM_SERVICE } from "../../apps/desktop/src/components/settings/service-catalog"; +import { copyProviderConfiguration } from "../../apps/desktop/src/components/settings/provider-copy"; import { api } from "../../apps/desktop/src/lib/api"; declare global { var providerApiStyleProbe: () => Promise; } @@ -134,6 +139,8 @@ globalThis.providerApiStyleProbe = async () => { const apiStyleLabel = (style: string) => i18n.t(API_STYLE_LABEL_KEYS[style as keyof typeof API_STYLE_LABEL_KEYS]); const customLabels = () => CUSTOM_PROVIDER_API_STYLES.map(apiStyleLabel); + const customSetupLabels = () => CUSTOM_PROVIDER_SETUP_API_STYLES.map((style) => + i18n.t(PROVIDER_SETUP_API_STYLE_LABEL_KEYS[style])); /* The API format row is a SettingsMenuSelect: a trigger button that reports the @@ -243,11 +250,12 @@ globalThis.providerApiStyleProbe = async () => { `${locale}: StepFun discovery used the wrong route or credentials`); const stepfunCheckbox = () => [...document.querySelectorAll('input[type="checkbox"]')] .find((input) => input.closest("label")?.textContent?.includes("step-5-preview")); - await until(() => Boolean(stepfunCheckbox()), "StepFun discovered model"); - // Exercise explicit selection even when the recommendation preselected it. - if (stepfunCheckbox()!.checked) click(stepfunCheckbox()); + await until(() => stepfunCheckbox()?.checked === true, "StepFun recommended model selected"); + // Exercise explicit selection after recommendation has settled. click(stepfunCheckbox()); - assert(stepfunCheckbox()!.checked, `${locale}: StepFun model was not selected`); + await until(() => stepfunCheckbox()?.checked === false, "StepFun model deselected"); + click(stepfunCheckbox()); + await until(() => stepfunCheckbox()?.checked === true, "StepFun model selected"); await until(() => !control("settings.saveProvider").disabled, "StepFun save enabled"); click(control("settings.saveProvider")); await until(() => creates.length === beforeStepfunCreate + 1, "StepFun saved"); @@ -270,8 +278,8 @@ globalThis.providerApiStyleProbe = async () => { assert(!document.querySelector("[data-service-id]"), `${locale}: chooser stayed open after a pick`); await openApiStyleMenu(); const newCustomOptions = optionSnapshot(); - assert(JSON.stringify(newCustomOptions.map((option) => option.label)) === JSON.stringify(customLabels()), - `${locale}: new custom form exposes an account format`); + assert(JSON.stringify(newCustomOptions.map((option) => option.label)) === JSON.stringify(customSetupLabels()), + `${locale}: new custom form does not expose the setup formats`); for (const style of ACCOUNT_ONLY_STYLES) { assert(!newCustomOptions.some((option) => option.label === apiStyleLabel(style)), `${locale}: new custom form offers the account format ${style}`); @@ -299,13 +307,15 @@ globalThis.providerApiStyleProbe = async () => { const localProbe = discoveries.at(-1)!; assert(localProbe.baseUrl === "http://127.0.0.1:11434/v1" && !localProbe.apiKey, `${locale}: local model discovery included an API key`); + const localModel = () => document.querySelector( + ".provider-models-row input", + ); await until( - () => Boolean(document.querySelector(".provider-models-row input")), + () => Boolean(localModel()), "local model discovery result", ); - const localModel = document.querySelector(".provider-models-row input"); - assert(localModel, `${locale}: the local service model was not selectable`); - if (!localModel.checked) click(localModel); + assert(localModel(), `${locale}: the local service model was not selectable`); + await until(() => localModel()?.checked === true, "local recommended model selected"); await until(() => !control("settings.saveProvider").disabled, "no-auth provider save enabled"); const beforeLocalCreate = creates.length; savedProvider = undefined;