feat(settings): configure Jev evaluation and model transitions

This commit is contained in:
yilsem committed 2026-10-13 01:07:58 +03:00
1 parent 369f6c2ca8
commit 883b1dc824
20 files changed
+1195 -37

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
// another version is restarted so an updated IDE never keeps an old bridge alive.
export const CODEX_BRIDGE_VERSION = '11'
export const CODEX_BRIDGE_VERSION = '12'
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
+44
View File
@@ -378,6 +378,7 @@ import { ensureLoginShellPath } from './shell-path'
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection'
import { createStagedTransitionStore } from './staged-transition'
import { createStreamThrottle } from './stream-throttle'
import { registerTerminalIpc } from './terminal-ipc'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
@@ -17192,6 +17193,21 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
// resulting state and asks for changes over IPC. Windows only (the login item is
// a Run-key value). All decisions live in electron/codex-link.ts.
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
const stagedTransition = createStagedTransitionStore(
path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'),
path.join(app.getPath('home'), '.typesafe', 'typesafe.env'),
value =>
execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'Add-Type -AssemblyName System.Security; $bytes=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); [Console]::Out.Write([Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser)))'
],
{ input: value, encoding: 'utf8', windowsHide: true, timeout: 15000 }
).trim()
)
const codexLink = createCodexLink(
createNodeCodexLinkDeps(
@@ -17243,6 +17259,34 @@ ipcMain.handle('aiturk:codex-link:get', async event => {
return codexLink.getStatus()
})
async function stagedTransitionState() {
const state = stagedTransition.get()
try {
const response = await fetch('http://127.0.0.1:8795/aiturk/codex-baglama/status', {
signal: AbortSignal.timeout(5000)
})
if (!response.ok) throw new Error('Bridge unavailable')
const body = await response.json()
const models = Array.isArray(body.jev_models)
? body.jev_models.filter(m => typeof m?.id === 'string' && typeof m?.name === 'string')
: []
return { ...state, models, modelsAvailable: Array.isArray(body.jev_models) }
} catch {
return { ...state, models: [], modelsAvailable: false }
}
}
ipcMain.handle('aiturk:staged-transition:get', async event => {
assertCodexLinkSender(event)
return stagedTransitionState()
})
ipcMain.handle('aiturk:staged-transition:set', async (event, patch) => {
assertCodexLinkSender(event)
stagedTransition.save(patch)
return stagedTransitionState()
})
ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
assertCodexLinkSender(event)
+2
View File
@@ -295,6 +295,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
...(process.platform === 'win32'
? {
codexLink: {
getStagedTransition: () => ipcRenderer.invoke('aiturk:staged-transition:get'),
setStagedTransition: patch => ipcRenderer.invoke('aiturk:staged-transition:set', patch),
get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
set: patch => ipcRenderer.invoke('aiturk:codex-link:set', patch)
}
@@ -0,0 +1,27 @@
export interface StagedTransitionSettings {
enabled: boolean
endpoint: string
model: string
fallbackModel: string
rejectionRules: { from: string; to: string }[]
exemptModels: string[]
thresholds: { ret: number; sart: number }
questions: { ret: string; sart: string }
criteria: { ret: { true: string; false: string }; sart: { true: string; false: string } }
timeoutSeconds: number
maxConcurrent: number
queueWaitSeconds: number
failureAction: 'pass' | 'next'
}
export interface StagedTransitionState {
settings: StagedTransitionSettings
keyConfigured: boolean
models?: { id: string; name: string }[]
modelsAvailable?: boolean
}
export interface StagedTransitionPatch {
settings: StagedTransitionSettings
apiKey?: string
}
@@ -0,0 +1,53 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createStagedTransitionStore, stagedTransitionDefaults, validateStagedTransition } from './staged-transition'
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('staged transition settings', () => {
it('persists rules and protects keys without returning a secret', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
dirs.push(dir)
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'encrypted-test-value')
const settings = structuredClone(stagedTransitionDefaults)
settings.fallbackModel = 'fallback'
settings.rejectionRules = [{ from: 'model-a', to: 'model-b' }]
const saved = store.save({ settings, apiKey: 'dummy-secret' })
expect(saved.settings.rejectionRules).toEqual(settings.rejectionRules)
expect(saved.keyConfigured).toBe(true)
expect(JSON.stringify(saved)).not.toContain('dummy-secret')
expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('dummy-secret')
expect(store.get()).toEqual(saved)
store.save({ settings: { ...settings, timeoutSeconds: 123 } })
expect(store.get().keyConfigured).toBe(true)
})
it('rejects invalid rules without replacing the saved settings', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
dirs.push(dir)
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x)
const before = store.save({ settings: structuredClone(stagedTransitionDefaults) })
expect(() =>
store.save({
settings: {
...before.settings,
rejectionRules: [
{ from: 'a', to: 'b' },
{ from: 'b', to: 'a' }
]
}
})
).toThrow()
expect(store.get().settings).toEqual(before.settings)
expect(() => validateStagedTransition({ ...before.settings, maxConcurrent: -1 })).toThrow()
expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow()
expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow()
})
})
+174
View File
@@ -0,0 +1,174 @@
import fs from 'node:fs'
import path from 'node:path'
import defaults from '../assets/codex-bridge/jev-settings-defaults.json'
import type { StagedTransitionPatch, StagedTransitionSettings, StagedTransitionState } from './staged-transition-types'
export const stagedTransitionDefaults = defaults as StagedTransitionSettings
export function validateStagedTransition(value: unknown): StagedTransitionSettings {
if (!value || typeof value !== 'object') {
throw new Error('Invalid settings')
}
const v = value as StagedTransitionSettings
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
const list = (s: unknown, allowEmpty: boolean) =>
Array.isArray(s) &&
(allowEmpty || s.length > 0) &&
s.length <= 64 &&
s.every(x => text(x, 256)) &&
new Set(s.map(x => x.trim())).size === s.length
const seconds = (n: unknown) => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 3600
if (typeof v.enabled !== 'boolean' || !text(v.endpoint, 2048) || !text(v.model, 256)) {
throw new Error('Invalid connection settings')
}
const url = new URL(v.endpoint)
if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
throw new Error('HTTPS endpoint required')
}
if (
!text(v.fallbackModel, 256) ||
!list(v.exemptModels, true) ||
!Array.isArray(v.rejectionRules) ||
v.rejectionRules.length > 64 ||
v.rejectionRules.some(r => !text(r.from, 256) || !text(r.to, 256) || r.from.trim() === r.to.trim()) ||
new Set(v.rejectionRules.map(r => r.from.trim())).size !== v.rejectionRules.length
) {
throw new Error('Invalid model rules')
}
const routes = new Map(v.rejectionRules.map(r => [r.from.trim(), r.to.trim()]))
for (const start of routes.keys()) {
const seen = new Set<string>()
let current: string | undefined = start
while (current && !v.exemptModels.includes(current)) {
if (seen.has(current)) {
throw new Error('Model rules contain a cycle')
}
seen.add(current)
current = routes.get(current) ?? (current === v.fallbackModel.trim() ? undefined : v.fallbackModel.trim())
}
}
for (const id of ['ret', 'sart'] as const) {
if (
!text(v.questions?.[id]) ||
typeof v.thresholds?.[id] !== 'number' ||
!Number.isFinite(v.thresholds[id]) ||
v.thresholds[id] < 0 ||
v.thresholds[id] > 1
) {
throw new Error('Invalid evaluation rule')
}
for (const outcome of ['true', 'false'] as const) {
if (typeof v.criteria?.[id]?.[outcome] !== 'string' || v.criteria[id][outcome].length > 10000) {
throw new Error('Invalid decision criteria')
}
}
}
if (
!seconds(v.timeoutSeconds) ||
!seconds(v.queueWaitSeconds) ||
!Number.isInteger(v.maxConcurrent) ||
v.maxConcurrent < 0 ||
v.maxConcurrent > 512 ||
!['pass', 'next'].includes(v.failureAction)
) {
throw new Error('Invalid request limits')
}
return {
enabled: v.enabled,
endpoint: v.endpoint.trim(),
model: v.model.trim(),
fallbackModel: v.fallbackModel.trim(),
rejectionRules: v.rejectionRules.map(r => ({ from: r.from.trim(), to: r.to.trim() })),
exemptModels: v.exemptModels.map(x => x.trim()),
questions: { ...v.questions },
thresholds: { ...v.thresholds },
criteria: structuredClone(v.criteria),
timeoutSeconds: v.timeoutSeconds,
maxConcurrent: v.maxConcurrent,
queueWaitSeconds: v.queueWaitSeconds,
failureAction: v.failureAction
}
}
export function createStagedTransitionStore(
directory: string,
legacyKeyFile: string,
protect: (value: string) => string
) {
const file = path.join(directory, 'jev-settings.json')
const read = (): { settings: StagedTransitionSettings; protectedKey?: string } => {
if (!fs.existsSync(file)) {
return { settings: structuredClone(stagedTransitionDefaults) }
}
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
return { settings: validateStagedTransition(data.settings), protectedKey: data.protectedKey }
}
const state = (): StagedTransitionState => {
const current = read()
let legacy = Boolean(process.env.TYPESAFE_API_KEY?.trim())
if (!legacy && fs.existsSync(legacyKeyFile)) {
const match = /^\s*TYPESAFE_API_KEY\s*=\s*(.*)$/m.exec(fs.readFileSync(legacyKeyFile, 'utf8'))
legacy = Boolean(
match?.[1]
?.trim()
.replace(/^(['"])(.*)\1$/, '$2')
.trim()
)
}
return { settings: current.settings, keyConfigured: Boolean(current.protectedKey) || legacy }
}
return {
get: state,
save(patch: StagedTransitionPatch): StagedTransitionState {
const settings = validateStagedTransition(patch?.settings)
if (patch.apiKey !== undefined && (typeof patch.apiKey !== 'string' || patch.apiKey.length > 8192)) {
throw new Error('Invalid API key')
}
const current = read()
const protectedKey =
patch.apiKey === undefined
? current.protectedKey
: patch.apiKey.trim()
? protect(patch.apiKey.trim())
: undefined
fs.mkdirSync(directory, { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
try {
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey }, null, 2), { mode: 0o600 })
fs.renameSync(tmp, file)
} finally {
if (fs.existsSync(tmp)) {
fs.unlinkSync(tmp)
}
}
return state()
}
}
}