Codex only received combos and the models named in the IDE config, so none of the models behind the providers added to 9Router showed up. Those single models are now always added; 9Router's combos stay hidden. The copy of the same model that comes through OmniRoute is still added only when the IDE config names it, so each model is listed once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HRDGW1LiKcDUZYUdKBoWYW
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
/**
|
|
* Codex Link ("Codex Bağlama") — the pure half.
|
|
*
|
|
* AITURK IDE ships a small local Python bridge that lets the OpenAI Codex
|
|
* installed on this machine use the IDE's models and combos. Turning the
|
|
* feature on means editing a file the IDE does not own (Codex's `config.toml`)
|
|
* and registering a login item, so every text transformation lives here as a
|
|
* dependency-free, byte-preserving function that can be proven without
|
|
* Electron, a filesystem, or a registry. `codex-link.ts` owns the decisions and
|
|
* the side effects.
|
|
*
|
|
* Invariants:
|
|
* - Only the line carrying OUR trailing comment is ever written or removed.
|
|
* - An `openai_base_url` that is not ours means someone else's bridge is in
|
|
* charge: report `foreign` and change nothing.
|
|
* - Everything else in the file — including its line endings and BOM — comes
|
|
* back byte for byte.
|
|
*/
|
|
|
|
export const CODEX_LINK_PORT = 8795
|
|
export const CODEX_LINK_ORIGIN = `http://127.0.0.1:${CODEX_LINK_PORT}`
|
|
export const CODEX_LINK_STATUS_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/status`
|
|
export const CODEX_LINK_STOP_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/stop`
|
|
export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/refresh`
|
|
|
|
/** The trailing comment that marks the line as ours. */
|
|
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 = '4'
|
|
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'
|
|
export const CODEX_LINK_RUN_VALUE = 'AITURK-IDE-Codex-Baglama'
|
|
export const CODEX_LINK_LAUNCHER_NAME = 'baslat.pyw'
|
|
|
|
// Windows documents Run-key command lines as "no longer than 260 characters".
|
|
// A longer value is not reliably started at sign-in, so the direct command is
|
|
// only used when it fits (see autostartPlan).
|
|
export const CODEX_LINK_RUN_COMMAND_MAX = 260
|
|
|
|
/** Who owns the top-level `openai_base_url` in a Codex config. */
|
|
export type CodexConfigOwner = 'absent' | 'foreign' | 'ours'
|
|
|
|
interface Line {
|
|
eol: string
|
|
text: string
|
|
}
|
|
|
|
const BOM = '\uFEFF'
|
|
const KEY_PATTERN = /^[ \t]*(?:openai_base_url|"openai_base_url"|'openai_base_url')[ \t]*=/
|
|
|
|
// Split into lines WITHOUT losing anything: joining `text + eol` of every entry
|
|
// reproduces the input exactly, whatever mix of \n, \r\n and \r it used.
|
|
function splitLines(source: string): Line[] {
|
|
const lines: Line[] = []
|
|
let start = 0
|
|
|
|
while (start < source.length) {
|
|
let end = start
|
|
|
|
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') {
|
|
end += 1
|
|
}
|
|
|
|
let eol = ''
|
|
|
|
if (end < source.length) {
|
|
eol = source[end] === '\r' && source[end + 1] === '\n' ? '\r\n' : source[end]
|
|
}
|
|
|
|
lines.push({ eol, text: source.slice(start, end) })
|
|
start = end + eol.length
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
function joinLines(lines: Line[]): string {
|
|
return lines.map(line => line.text + line.eol).join('')
|
|
}
|
|
|
|
function splitBom(source: string): { body: string; bom: string } {
|
|
return source.startsWith(BOM) ? { body: source.slice(1), bom: BOM } : { body: source, bom: '' }
|
|
}
|
|
|
|
type MultilineQuote = "'''" | '"""' | null
|
|
|
|
interface ValueState {
|
|
depth: number
|
|
multiline: MultilineQuote
|
|
}
|
|
|
|
// Index just past the `=` of a `key = value` line, or the line length when the
|
|
// line holds no value (blank, comment, malformed). Quoted keys may contain `=`.
|
|
function valueStart(text: string): number {
|
|
let pos = 0
|
|
|
|
while (pos < text.length) {
|
|
const char = text[pos]
|
|
|
|
if (char === '#') {
|
|
return text.length
|
|
}
|
|
|
|
if (char === '=') {
|
|
return pos + 1
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
pos += 1
|
|
|
|
while (pos < text.length && text[pos] !== char) {
|
|
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
|
}
|
|
}
|
|
|
|
pos += 1
|
|
}
|
|
|
|
return text.length
|
|
}
|
|
|
|
// Walk a value just far enough to know whether the NEXT line is still inside it
|
|
// (multi-line string or array). That is the only TOML the top-level scan needs:
|
|
// without it, text inside a multi-line value could pass for a key or a table
|
|
// header.
|
|
function scanValue(text: string, from: number, state: ValueState): ValueState {
|
|
let { depth, multiline } = state
|
|
let pos = from
|
|
|
|
while (pos < text.length) {
|
|
if (multiline) {
|
|
if (multiline === '"""' && text[pos] === '\\') {
|
|
pos += 2
|
|
|
|
continue
|
|
}
|
|
|
|
if (text.startsWith(multiline, pos)) {
|
|
pos += 3
|
|
|
|
// Up to two quotes may sit directly before the closing delimiter.
|
|
while (text[pos] === multiline[0]) {
|
|
pos += 1
|
|
}
|
|
|
|
multiline = null
|
|
|
|
continue
|
|
}
|
|
|
|
pos += 1
|
|
|
|
continue
|
|
}
|
|
|
|
const char = text[pos]
|
|
|
|
if (char === '#') {
|
|
break
|
|
}
|
|
|
|
if (text.startsWith('"""', pos) || text.startsWith("'''", pos)) {
|
|
multiline = text.slice(pos, pos + 3) as MultilineQuote
|
|
pos += 3
|
|
|
|
continue
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
pos += 1
|
|
|
|
while (pos < text.length && text[pos] !== char) {
|
|
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
|
}
|
|
|
|
pos += 1
|
|
|
|
continue
|
|
}
|
|
|
|
if (char === '[' || char === '{') {
|
|
depth += 1
|
|
} else if (char === ']' || char === '}') {
|
|
depth = Math.max(0, depth - 1)
|
|
}
|
|
|
|
pos += 1
|
|
}
|
|
|
|
return { depth, multiline }
|
|
}
|
|
|
|
// Indices of the top-level `openai_base_url` lines: those before the first
|
|
// `[table]` header, outside comments and multi-line values.
|
|
function topLevelEntries(lines: Line[]): { index: number; ours: boolean }[] {
|
|
const entries: { index: number; ours: boolean }[] = []
|
|
let state: ValueState = { depth: 0, multiline: null }
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const { text } = lines[index]
|
|
let from = 0
|
|
|
|
if (!state.multiline && state.depth === 0) {
|
|
if (text.trimStart().startsWith('[')) {
|
|
break
|
|
}
|
|
|
|
if (KEY_PATTERN.test(text)) {
|
|
// Ours by marker or by value: Codex may rewrite its config and drop the comment.
|
|
entries.push({
|
|
index,
|
|
ours: text.includes(CODEX_LINK_MARKER) || text.includes(`"${CODEX_LINK_ORIGIN}/backend-api/codex"`)
|
|
})
|
|
}
|
|
|
|
from = valueStart(text)
|
|
}
|
|
|
|
state = scanValue(text, from, state)
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
function ownerOf(entries: { ours: boolean }[]): CodexConfigOwner {
|
|
if (entries.some(entry => !entry.ours)) {
|
|
return 'foreign'
|
|
}
|
|
|
|
return entries.length > 0 ? 'ours' : 'absent'
|
|
}
|
|
|
|
/** Who owns the top-level `openai_base_url` of this config text. */
|
|
export function inspectCodexConfig(source: string): CodexConfigOwner {
|
|
return ownerOf(topLevelEntries(splitLines(splitBom(source).body)))
|
|
}
|
|
|
|
export interface CodexConfigEdit {
|
|
changed: boolean
|
|
owner: CodexConfigOwner
|
|
text: string
|
|
}
|
|
|
|
/**
|
|
* Point Codex at the bridge. Idempotent; a foreign `openai_base_url` leaves the
|
|
* text untouched and reports `foreign`.
|
|
*
|
|
* The line goes on the very first line: top-level keys must precede the first
|
|
* table header, and the top of the file is the one position that is always
|
|
* top-level without having to understand the values below it.
|
|
*/
|
|
export function addCodexLinkLine(source: string): CodexConfigEdit {
|
|
const { body, bom } = splitBom(source)
|
|
const lines = splitLines(body)
|
|
const entries = topLevelEntries(lines)
|
|
const owner = ownerOf(entries)
|
|
|
|
if (owner === 'foreign') {
|
|
return { changed: false, owner, text: source }
|
|
}
|
|
|
|
if (owner === 'ours') {
|
|
const [first, ...duplicates] = entries
|
|
|
|
if (duplicates.length === 0 && lines[first.index].text === CODEX_LINK_LINE) {
|
|
return { changed: false, owner, text: source }
|
|
}
|
|
|
|
// A stale copy of our line (older address) or an accidental duplicate:
|
|
// normalize to exactly one current line, in place.
|
|
const drop = new Set(duplicates.map(entry => entry.index))
|
|
lines[first.index] = { ...lines[first.index], text: CODEX_LINK_LINE }
|
|
|
|
return { changed: true, owner, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
|
}
|
|
|
|
const eol = body.includes('\r\n') ? '\r\n' : '\n'
|
|
|
|
return { changed: true, owner: 'ours', text: `${bom}${CODEX_LINK_LINE}${eol}${body}` }
|
|
}
|
|
|
|
/** Remove our line (and only ours). A foreign key is left exactly as it was. */
|
|
export function removeCodexLinkLine(source: string): CodexConfigEdit {
|
|
const { body, bom } = splitBom(source)
|
|
const lines = splitLines(body)
|
|
const entries = topLevelEntries(lines)
|
|
const drop = new Set(entries.filter(entry => entry.ours).map(entry => entry.index))
|
|
const remaining = ownerOf(entries.filter(entry => !entry.ours))
|
|
|
|
if (drop.size === 0) {
|
|
return { changed: false, owner: remaining, text: source }
|
|
}
|
|
|
|
return { changed: true, owner: remaining, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
|
}
|
|
|
|
// ── Preference file ─────────────────────────────────────────────────────────
|
|
|
|
export interface CodexLinkSettings {
|
|
enabled: boolean
|
|
/** Every field of the stored object, so a rewrite keeps what it did not set. */
|
|
stored: Record<string, unknown>
|
|
}
|
|
|
|
/** Missing, unreadable or malformed means ON: the feature is on by default. */
|
|
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
|
|
if (typeof raw !== 'string') {
|
|
return { enabled: true, stored: {} }
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { enabled: true, stored: {} }
|
|
}
|
|
|
|
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> }
|
|
} catch {
|
|
return { enabled: true, stored: {} }
|
|
}
|
|
}
|
|
|
|
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string {
|
|
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
|
|
|
|
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
|
|
}
|
|
|
|
// ── Bridge configuration ────────────────────────────────────────────────────
|
|
|
|
/** `bridge.json`, rewritten on every apply. */
|
|
export function codexBridgeConfig() {
|
|
return {
|
|
port: CODEX_LINK_PORT,
|
|
chatgpt_backend: 'https://chatgpt.com',
|
|
routes: {},
|
|
nine_router_catalog: { enabled: false },
|
|
pano_kanca_user: false,
|
|
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 3 }
|
|
}
|
|
}
|
|
|
|
export function serializeCodexBridgeConfig(): string {
|
|
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n`
|
|
}
|
|
|
|
// ── Bridge command line ─────────────────────────────────────────────────────
|
|
|
|
export interface CodexBridgeLaunch {
|
|
bridgeHome: string
|
|
bridgeScript: string
|
|
hermesHome: string
|
|
}
|
|
|
|
/** Arguments after the interpreter — identical for the spawn and the login item. */
|
|
export function codexBridgeArgs({ bridgeHome, bridgeScript, hermesHome }: CodexBridgeLaunch): string[] {
|
|
return [bridgeScript, '--home', bridgeHome, '--hermes-home', hermesHome]
|
|
}
|
|
|
|
// A trailing backslash would escape the closing quote (`"C:\dir\"`).
|
|
function quoted(value: string): string {
|
|
return `"${value.replace(/[\\/]+$/, '')}"`
|
|
}
|
|
|
|
/** `"<pythonw>" "<bridge.py>" --home "<data>" --hermes-home "<agent>"` */
|
|
export function codexBridgeCommandLine(python: string, launch: CodexBridgeLaunch): string {
|
|
const [script, homeFlag, home, hermesFlag, hermes] = codexBridgeArgs(launch)
|
|
|
|
return `${quoted(python)} ${quoted(script)} ${homeFlag} ${quoted(home)} ${hermesFlag} ${quoted(hermes)}`
|
|
}
|
|
|
|
/**
|
|
* A tiny windowless launcher for the login item, used when the direct command
|
|
* would not fit a Run value. It runs the bridge exactly as the direct command
|
|
* would: same script, same arguments, the script's folder first on sys.path so
|
|
* its helper modules import.
|
|
*/
|
|
export function codexBridgeLauncherSource(launch: CodexBridgeLaunch): string {
|
|
const argv = JSON.stringify(codexBridgeArgs(launch))
|
|
|
|
return [
|
|
'# Written by AITURK IDE (Settings > Codex Baglama). Regenerated on every sync; do not edit.',
|
|
'import os',
|
|
'import runpy',
|
|
'import sys',
|
|
'',
|
|
`ARGV = ${argv}`,
|
|
'',
|
|
'sys.argv = list(ARGV)',
|
|
'sys.path.insert(0, os.path.dirname(ARGV[0]))',
|
|
"runpy.run_path(ARGV[0], run_name='__main__')",
|
|
''
|
|
].join('\n')
|
|
}
|
|
|
|
export interface CodexAutostartPlan {
|
|
command: string
|
|
/** Launcher file to write first; null when the direct command is used. */
|
|
launcher: { path: string; source: string } | null
|
|
}
|
|
|
|
/**
|
|
* The login-item command. Prefer the direct bridge command; fall back to the
|
|
* launcher when that exceeds the Run-value limit. Returns null when not even
|
|
* the launcher form fits (nothing is registered rather than something that
|
|
* silently never starts).
|
|
*/
|
|
export function codexAutostartPlan(
|
|
python: string,
|
|
launch: CodexBridgeLaunch,
|
|
launcherPath: string
|
|
): CodexAutostartPlan | null {
|
|
const direct = codexBridgeCommandLine(python, launch)
|
|
|
|
if (direct.length <= CODEX_LINK_RUN_COMMAND_MAX) {
|
|
return { command: direct, launcher: null }
|
|
}
|
|
|
|
const viaLauncher = `${quoted(python)} ${quoted(launcherPath)}`
|
|
|
|
if (viaLauncher.length > CODEX_LINK_RUN_COMMAND_MAX) {
|
|
return null
|
|
}
|
|
|
|
return { command: viaLauncher, launcher: { path: launcherPath, source: codexBridgeLauncherSource(launch) } }
|
|
}
|