feat(codex-bridge): decision-model stages, Dify rules and beta.39
Owner request, 14 October 2026: use the decision model (Jev / Celeris) at several stages, let Dify supply the rules, code it without new bugs. Every new stage is switched on in Settings > Kademeli Gecis and starts disabled, so the upgrade changes no behaviour by itself. - Settings schema 2 (Python and TypeScript validate the same rules); a schema 1 file is filled from the defaults. Default chain is now Katman-1 -> Katman-2 -> Katman-3 with Katman-3, Chat-Model and bekci-sesli-bekci always passing (owner decision of the same day). - watchModels: the Bekci correction channel follows a settings list instead of the hard-coded Sesli-Hizli-Zeki. - prefilter: a predicted refusal starts the step at the chosen layer. - autoRoute: virtual "Otomatik" model; one decision per user turn picks the combo, tool steps of the same turn keep it. - finishCheck: an answer that stops early goes back to the same model once. - A layer whose window cannot hold the request is skipped. - policy: stage rules from a Dify workflow (karar_politikasi.py), fetched in the background, validated, last good copy kept; turns never wait. - karar_sor.py: verifierCheck for the dynamic-workflow acceptance step. - Bridge version 19, IDE 1.0.0-beta.39. Tests: 299 bridge tests, 36 electron tests; live run on a side bridge (port 8797) with Celeris and the AITURK gateway for every stage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8f53b090f7
commit
7c4ef18df0
18 files changed
+1672
-119
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 = '18'
|
||||
export const CODEX_BRIDGE_VERSION = '19'
|
||||
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'
|
||||
|
||||
@@ -12,11 +12,35 @@ export interface StagedTransitionSettings {
|
||||
maxConcurrent: number
|
||||
queueWaitSeconds: number
|
||||
failureAction: 'pass' | 'next'
|
||||
// Schema 2 (14 Oct 2026). A schema 1 file gets these from the defaults; every stage starts disabled.
|
||||
watchModels: string[]
|
||||
prefilter: { enabled: boolean; question: string; threshold: number; target: string; timeoutSeconds: number }
|
||||
autoRoute: {
|
||||
enabled: boolean
|
||||
model: string
|
||||
timeoutSeconds: number
|
||||
questions: { zor: string; arastirma: string }
|
||||
thresholds: { zor: number; arastirma: number }
|
||||
targets: { basit: string; zor: string; arastirma: string }
|
||||
}
|
||||
finishCheck: { enabled: boolean; question: string; threshold: number; note: string; timeoutSeconds: number }
|
||||
verifierCheck: { enabled: boolean; question: string; threshold: number }
|
||||
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number }
|
||||
}
|
||||
|
||||
export interface StagedTransitionPolicyStatus {
|
||||
fetchedAt?: string
|
||||
lastGoodAt?: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
sections: string[]
|
||||
}
|
||||
|
||||
export interface StagedTransitionState {
|
||||
settings: StagedTransitionSettings
|
||||
keyConfigured: boolean
|
||||
policyKeyConfigured?: boolean
|
||||
policyStatus?: StagedTransitionPolicyStatus | null
|
||||
models?: { id: string; name: string }[]
|
||||
modelsAvailable?: boolean
|
||||
}
|
||||
@@ -24,4 +48,5 @@ export interface StagedTransitionState {
|
||||
export interface StagedTransitionPatch {
|
||||
settings: StagedTransitionSettings
|
||||
apiKey?: string
|
||||
policyApiKey?: string
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import path from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createStagedTransitionStore, stagedTransitionDefaults, validateStagedTransition } from './staged-transition'
|
||||
import {
|
||||
createStagedTransitionStore,
|
||||
readPolicyStatus,
|
||||
stagedTransitionDefaults,
|
||||
validateStagedTransition
|
||||
} from './staged-transition'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => {
|
||||
@@ -50,4 +55,56 @@ describe('staged transition settings', () => {
|
||||
expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow()
|
||||
})
|
||||
it('loads a schema 1 file with every new stage disabled and keeps unknown-free output', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const old = structuredClone(stagedTransitionDefaults) as unknown as Record<string, unknown>
|
||||
|
||||
for (const key of ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy']) {
|
||||
delete old[key]
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'jev-settings.json'), JSON.stringify({ settings: old, protectedKey: 'k' }))
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x)
|
||||
const state = store.get()
|
||||
expect(state.settings.prefilter.enabled).toBe(false)
|
||||
expect(state.settings.autoRoute.enabled).toBe(false)
|
||||
expect(state.settings.finishCheck.enabled).toBe(false)
|
||||
expect(state.settings.verifierCheck.enabled).toBe(false)
|
||||
expect(state.settings.policy.source).toBe('local')
|
||||
expect(state.keyConfigured).toBe(true)
|
||||
const partial = { ...structuredClone(stagedTransitionDefaults), prefilter: { enabled: true, threshold: 0.8 } }
|
||||
const filled = validateStagedTransition(partial)
|
||||
expect(filled.prefilter.enabled).toBe(true)
|
||||
expect(filled.prefilter.threshold).toBe(0.8)
|
||||
expect(filled.prefilter.target).toBe(stagedTransitionDefaults.prefilter.target)
|
||||
})
|
||||
it('keeps the Dify key protected and reports the last rules fetch', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
|
||||
dirs.push(dir)
|
||||
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'protected-value')
|
||||
const settings = { ...structuredClone(stagedTransitionDefaults), policy: { ...stagedTransitionDefaults.policy, source: 'dify' as const } }
|
||||
const saved = store.save({ settings, policyApiKey: 'app-dummy-secret' })
|
||||
expect(saved.policyKeyConfigured).toBe(true)
|
||||
expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('app-dummy-secret')
|
||||
expect(store.save({ settings }).policyKeyConfigured).toBe(true)
|
||||
expect(store.save({ settings, policyApiKey: '' }).policyKeyConfigured).toBe(false)
|
||||
expect(readPolicyStatus(dir)).toBeNull()
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'jev-policy.json'),
|
||||
JSON.stringify({ fetchedAt: 'now', ok: true, error: 'Dify down', overrides: { autoRoute: { enabled: true } } })
|
||||
)
|
||||
expect(store.get().policyStatus).toEqual({ fetchedAt: 'now', lastGoodAt: undefined, ok: true, error: 'Dify down', sections: ['autoRoute'] })
|
||||
})
|
||||
it('rejects invalid decision stage values', () => {
|
||||
const base = structuredClone(stagedTransitionDefaults)
|
||||
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, threshold: 2 } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, timeoutSeconds: 0 } })).toThrow()
|
||||
expect(() =>
|
||||
validateStagedTransition({ ...base, autoRoute: { ...base.autoRoute, targets: { ...base.autoRoute.targets, zor: 'Otomatik' } } })
|
||||
).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, finishCheck: { ...base.finishCheck, note: '' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
|
||||
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -3,16 +3,70 @@ import path from 'node:path'
|
||||
|
||||
import defaults from '../assets/codex-bridge/jev-settings-defaults.json'
|
||||
|
||||
import type { StagedTransitionPatch, StagedTransitionSettings, StagedTransitionState } from './staged-transition-types'
|
||||
import type {
|
||||
StagedTransitionPatch,
|
||||
StagedTransitionPolicyStatus,
|
||||
StagedTransitionSettings,
|
||||
StagedTransitionState
|
||||
} from './staged-transition-types'
|
||||
|
||||
export const stagedTransitionDefaults = defaults as StagedTransitionSettings
|
||||
|
||||
export function validateStagedTransition(value: unknown): StagedTransitionSettings {
|
||||
if (!value || typeof value !== 'object') {
|
||||
// Sections added in schema 2 (14 Oct 2026). Mirrors codex-bridge/jev_settings.py: a missing section or
|
||||
// field comes from the defaults, so a schema 1 file stays valid and every new stage starts disabled.
|
||||
const SECTIONS = ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy'] as const
|
||||
const POLICY_FILE = 'jev-policy.json'
|
||||
|
||||
type Plain = Record<string, unknown>
|
||||
|
||||
const isPlain = (value: unknown): value is Plain => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
function fill(value: unknown, fallback: unknown): unknown {
|
||||
if (!isPlain(fallback) || !isPlain(value)) {
|
||||
return value
|
||||
}
|
||||
const out: Plain = { ...value }
|
||||
|
||||
for (const [key, def] of Object.entries(fallback)) {
|
||||
out[key] = key in value ? fill(value[key], def) : structuredClone(def)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function withSchemaDefaults(value: unknown): unknown {
|
||||
if (!isPlain(value)) {
|
||||
return value
|
||||
}
|
||||
const out: Plain = { ...value }
|
||||
const defs = stagedTransitionDefaults as unknown as Plain
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
out[section] = section in value ? fill(value[section], defs[section]) : structuredClone(defs[section])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
|
||||
const probability = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1
|
||||
const integer = (n: unknown, min: number, max: number): n is number =>
|
||||
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
|
||||
|
||||
function https(value: unknown): value is string {
|
||||
if (!text(value, 2048)) {
|
||||
return false
|
||||
}
|
||||
const url = new URL(value)
|
||||
|
||||
return url.protocol === 'https:' && !url.username && !url.password && !url.hash
|
||||
}
|
||||
|
||||
export function validateStagedTransition(input: unknown): StagedTransitionSettings {
|
||||
if (!input || typeof input !== '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 v = withSchemaDefaults(input) as StagedTransitionSettings
|
||||
|
||||
const list = (s: unknown, allowEmpty: boolean) =>
|
||||
Array.isArray(s) &&
|
||||
@@ -21,15 +75,13 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
|
||||
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
|
||||
const seconds = (n: unknown) => integer(n, 1, 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) {
|
||||
if (!https(v.endpoint)) {
|
||||
throw new Error('HTTPS endpoint required')
|
||||
}
|
||||
|
||||
@@ -60,13 +112,7 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
if (!text(v.questions?.[id]) || !probability(v.thresholds?.[id])) {
|
||||
throw new Error('Invalid evaluation rule')
|
||||
}
|
||||
|
||||
@@ -80,28 +126,124 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
|
||||
if (
|
||||
!seconds(v.timeoutSeconds) ||
|
||||
!seconds(v.queueWaitSeconds) ||
|
||||
!Number.isInteger(v.maxConcurrent) ||
|
||||
v.maxConcurrent < 0 ||
|
||||
v.maxConcurrent > 512 ||
|
||||
!integer(v.maxConcurrent, 0, 512) ||
|
||||
!['pass', 'next'].includes(v.failureAction)
|
||||
) {
|
||||
throw new Error('Invalid request limits')
|
||||
}
|
||||
|
||||
if (!list(v.watchModels, true)) {
|
||||
throw new Error('Invalid watch models')
|
||||
}
|
||||
|
||||
const p = v.prefilter
|
||||
|
||||
if (
|
||||
typeof p?.enabled !== 'boolean' ||
|
||||
!text(p.question) ||
|
||||
!probability(p.threshold) ||
|
||||
!text(p.target, 256) ||
|
||||
!integer(p.timeoutSeconds, 1, 60)
|
||||
) {
|
||||
throw new Error('Invalid prefilter')
|
||||
}
|
||||
|
||||
const a = v.autoRoute
|
||||
|
||||
if (
|
||||
typeof a?.enabled !== 'boolean' ||
|
||||
!text(a.model, 256) ||
|
||||
!integer(a.timeoutSeconds, 1, 60) ||
|
||||
(['zor', 'arastirma'] as const).some(k => !text(a.questions?.[k]) || !probability(a.thresholds?.[k])) ||
|
||||
(['basit', 'zor', 'arastirma'] as const).some(k => !text(a.targets?.[k], 256))
|
||||
) {
|
||||
throw new Error('Invalid automatic routing')
|
||||
}
|
||||
|
||||
if (Object.values(a.targets).some(t => t.trim() === a.model.trim())) {
|
||||
throw new Error('Automatic routing cannot target itself')
|
||||
}
|
||||
|
||||
const f = v.finishCheck
|
||||
|
||||
if (
|
||||
typeof f?.enabled !== 'boolean' ||
|
||||
!text(f.question) ||
|
||||
!probability(f.threshold) ||
|
||||
!text(f.note, 4000) ||
|
||||
!integer(f.timeoutSeconds, 1, 60)
|
||||
) {
|
||||
throw new Error('Invalid finish check')
|
||||
}
|
||||
|
||||
const c = v.verifierCheck
|
||||
|
||||
if (typeof c?.enabled !== 'boolean' || !text(c.question) || !probability(c.threshold)) {
|
||||
throw new Error('Invalid verifier check')
|
||||
}
|
||||
|
||||
const pol = v.policy
|
||||
|
||||
if (!['local', 'dify'].includes(pol?.source) || !https(pol.endpoint) || !integer(pol.refreshMinutes, 1, 1440)) {
|
||||
throw new Error('Invalid policy source')
|
||||
}
|
||||
|
||||
const trimmed = (s: string) => s.trim()
|
||||
|
||||
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()),
|
||||
exemptModels: v.exemptModels.map(trimmed),
|
||||
questions: { ...v.questions },
|
||||
thresholds: { ...v.thresholds },
|
||||
criteria: structuredClone(v.criteria),
|
||||
timeoutSeconds: v.timeoutSeconds,
|
||||
maxConcurrent: v.maxConcurrent,
|
||||
queueWaitSeconds: v.queueWaitSeconds,
|
||||
failureAction: v.failureAction
|
||||
failureAction: v.failureAction,
|
||||
watchModels: v.watchModels.map(trimmed),
|
||||
prefilter: { ...p, target: p.target.trim() },
|
||||
autoRoute: {
|
||||
enabled: a.enabled,
|
||||
model: a.model.trim(),
|
||||
timeoutSeconds: a.timeoutSeconds,
|
||||
questions: { zor: a.questions.zor, arastirma: a.questions.arastirma },
|
||||
thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma },
|
||||
targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
|
||||
},
|
||||
finishCheck: {
|
||||
enabled: f.enabled,
|
||||
question: f.question,
|
||||
threshold: f.threshold,
|
||||
note: f.note,
|
||||
timeoutSeconds: f.timeoutSeconds
|
||||
},
|
||||
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
|
||||
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
|
||||
}
|
||||
}
|
||||
|
||||
// The bridge writes jev-policy.json after each Dify fetch; only a summary reaches the renderer.
|
||||
export function readPolicyStatus(directory: string): StagedTransitionPolicyStatus | null {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(path.join(directory, POLICY_FILE), 'utf8'))
|
||||
|
||||
if (!isPlain(data)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
fetchedAt: typeof data.fetchedAt === 'string' ? data.fetchedAt : undefined,
|
||||
lastGoodAt: typeof data.lastGoodAt === 'string' ? data.lastGoodAt : undefined,
|
||||
ok: data.ok === true,
|
||||
error: typeof data.error === 'string' && data.error ? data.error.slice(0, 500) : undefined,
|
||||
sections: isPlain(data.overrides) ? Object.keys(data.overrides) : []
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,13 +254,17 @@ export function createStagedTransitionStore(
|
||||
) {
|
||||
const file = path.join(directory, 'jev-settings.json')
|
||||
|
||||
const read = (): { settings: StagedTransitionSettings; protectedKey?: string } => {
|
||||
const read = (): { settings: StagedTransitionSettings; protectedKey?: string; protectedPolicyKey?: 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 }
|
||||
return {
|
||||
settings: validateStagedTransition(data.settings),
|
||||
protectedKey: data.protectedKey,
|
||||
protectedPolicyKey: data.protectedPolicyKey
|
||||
}
|
||||
}
|
||||
|
||||
const state = (): StagedTransitionState => {
|
||||
@@ -135,32 +281,40 @@ export function createStagedTransitionStore(
|
||||
)
|
||||
}
|
||||
|
||||
return { settings: current.settings, keyConfigured: Boolean(current.protectedKey) || legacy }
|
||||
return {
|
||||
settings: current.settings,
|
||||
keyConfigured: Boolean(current.protectedKey) || legacy,
|
||||
policyKeyConfigured: Boolean(current.protectedPolicyKey),
|
||||
policyStatus: readPolicyStatus(directory)
|
||||
}
|
||||
}
|
||||
|
||||
const secret = (value: unknown, name: string) => {
|
||||
if (value !== undefined && (typeof value !== 'string' || value.length > 8192)) {
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
secret(patch.apiKey, 'API key')
|
||||
secret(patch.policyApiKey, 'policy key')
|
||||
|
||||
const current = read()
|
||||
|
||||
const protectedKey =
|
||||
patch.apiKey === undefined
|
||||
? current.protectedKey
|
||||
: patch.apiKey.trim()
|
||||
? protect(patch.apiKey.trim())
|
||||
: undefined
|
||||
const update = (value: string | undefined, previous: string | undefined) =>
|
||||
value === undefined ? previous : value.trim() ? protect(value.trim()) : undefined
|
||||
|
||||
const protectedKey = update(patch.apiKey, current.protectedKey)
|
||||
const protectedPolicyKey = update(patch.policyApiKey, current.protectedPolicyKey)
|
||||
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
const tmp = `${file}.${process.pid}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey }, null, 2), { mode: 0o600 })
|
||||
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey, protectedPolicyKey }, null, 2), { mode: 0o600 })
|
||||
fs.renameSync(tmp, file)
|
||||
} finally {
|
||||
if (fs.existsSync(tmp)) {
|
||||
|
||||
Reference in new issue
Block a user