feat(settings): configure Jev evaluation and model transitions
This commit is contained in:
1 parent
369f6c2ca8
commit
883b1dc824
20 files changed
+1195
-37
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user