# PrimeRouter — Codex CLI install + configure (Windows). # Stores the key in %USERPROFILE%\.codex\auth.json (Codex's own credential # store) so it resolves from every launch context, and adopts the # model_provider id this machine already uses so `codex resume` keeps listing # sessions recorded before the switch. $ErrorActionPreference = "Stop" function Say-Ok ($msg) { Write-Host "[OK] $msg" -ForegroundColor Green } function Say-Warn ($msg) { Write-Host "[!] $msg" -ForegroundColor Yellow } function Say-Die ($msg) { Write-Host "[X] $msg" -ForegroundColor Red; exit 1 } function Confirm-Action { param([string]$Prompt) # Set by one-click installer wrappers to run unattended. if ($env:PRIMEROUTER_AUTO_CONFIRM -eq "1") { return $true } $reply = Read-Host "$Prompt [y/N]" return ($reply -match '^[Yy]$') } function Backup-IfExists { param([string]$Path) if (Test-Path -LiteralPath $Path) { $ts = [int][double]::Parse((Get-Date -UFormat %s)) $dst = "$Path.bak.$ts" Copy-Item -LiteralPath $Path -Destination $dst -Force Say-Ok "Backed up existing $Path -> $dst" } } $PrBaseUrl = "https://primerouter.ai/v1" $PrModel = if ($env:PRIMEROUTER_MODEL) { $env:PRIMEROUTER_MODEL } else { "gpt-5.6-sol" } $CodexDir = Join-Path $env:USERPROFILE ".codex" $CodexConf = Join-Path $CodexDir "config.toml" $CodexAuth = Join-Path $CodexDir "auth.json" $InstallBase = "https://primerouter.ai/install" # --- Shared npm registry selection (keep identical across PrimeRouter scripts) --- # Prefers the mirror registry (consistently fast from our users' networks; # the default registry is often reachable but too slow) and falls back to # the default registry when the mirror is unreachable. Respects a registry # the user already configured in .npmrc. $NpmMirrorRegistry = "https://registry.npmmirror.com" function Get-NpmRegistryArgs { $configured = "" try { $configured = npm config get registry 2>$null } catch { $configured = "" } if ($configured -and $configured -notmatch '^https://registry\.npmjs\.org/?$') { return @() } try { Invoke-WebRequest -Uri "$NpmMirrorRegistry/-/ping" -TimeoutSec 8 -UseBasicParsing -ErrorAction Stop | Out-Null return @("--registry=$NpmMirrorRegistry") } catch { Say-Warn "$NpmMirrorRegistry is unreachable from this network; using the default npm registry." return @() } } # --- End shared npm registry selection --- # --- Shared Codex provider-id adoption (keep identical across PrimeRouter # scripts) --- # Codex's resume picker lists only the sessions whose recorded model_provider # id equals the currently configured one (tui resume_picker -> # ThreadListParams.model_providers -> rollout ProviderMatcher; `codex resume # --last` filters the same way, and --all only widens the cwd filter). Writing # a fresh model_provider = "primerouter" therefore hides every session the user # recorded under a previous provider id. Nothing is deleted - the rollout files # stay in ~/.codex/sessions - but from the user's seat the history is gone. So # adopt whatever id this machine already uses. # Top-level model_provider = "x" only, i.e. before the first table header - # TOML scopes every key after [table] into that table, which is exactly the # trap the generated config warns about. A model_provider sitting below a # header is not the active provider (Codex ignores it and falls back), so # reading it would adopt an id no session was ever recorded under. function Get-ProviderIdFromConfig { if (-not (Test-Path -LiteralPath $CodexConf)) { return $null } foreach ($line in (Get-Content -LiteralPath $CodexConf -ErrorAction SilentlyContinue)) { if ($line -match '^\s*\[') { break } if ($line -match '^model_provider\s*=\s*"([^"]*)"') { return $Matches[1] } } return $null } # Fallback for users whose config.toml we already overwrote: the id is also # recorded in every rollout's SessionMeta line. Newest 200 plain rollouts is # plenty for a majority vote; compressed (.jsonl.zst) ones are skipped because # recent sessions - the ones that matter - are still plain. function Get-ProviderIdFromSessions { $dir = Join-Path $CodexDir "sessions" if (-not (Test-Path -LiteralPath $dir)) { return $null } $files = Get-ChildItem -LiteralPath $dir -Recurse -File -Filter "rollout-*.jsonl" ` -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 200 $votes = @{} foreach ($f in $files) { foreach ($line in (Get-Content -LiteralPath $f.FullName -TotalCount 5 -ErrorAction SilentlyContinue)) { if ($line -match '"model_provider"\s*:\s*"([^"]*)"') { $id = $Matches[1] if ($votes.ContainsKey($id)) { $votes[$id]++ } else { $votes[$id] = 1 } break } } } if ($votes.Count -eq 0) { return $null } return ($votes.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 1).Key } # TOML bare keys only - anything else would need quoting and is not worth the # blast radius, so fall back to our own id. function Test-ProviderId { param([string]$Id) return ($Id -match '^[A-Za-z0-9_-]+$') } function Resolve-ProviderId { if (-not [string]::IsNullOrWhiteSpace($env:PRIMEROUTER_CODEX_PROVIDER_ID)) { $forced = $env:PRIMEROUTER_CODEX_PROVIDER_ID.Trim() if (-not (Test-ProviderId $forced)) { Say-Die "PRIMEROUTER_CODEX_PROVIDER_ID must match [A-Za-z0-9_-]+." } return $forced } $detected = Get-ProviderIdFromConfig if ([string]::IsNullOrWhiteSpace($detected)) { $detected = Get-ProviderIdFromSessions } if ($detected) { $detected = $detected.Trim() } if ($detected -and (Test-ProviderId $detected) -and $detected -ne "primerouter") { Say-Ok "Reusing the existing provider id '$detected' so 'codex resume' keeps listing your old sessions." return $detected } return "primerouter" } # Renders the provider table. Emitted twice when we adopt a foreign id, so # `codex -c model_provider=primerouter` keeps working as an escape hatch. function Get-ProviderTable { param([string]$Id) # No env_key on purpose: ModelProviderInfo::api_key() turns a missing env # var into a hard error before Codex ever looks at auth.json, so env_key # and requires_openai_auth cannot coexist as "primary + fallback". return @" [model_providers.$Id] name = "PrimeRouter" wire_api = "responses" requires_openai_auth = true base_url = "$PrBaseUrl" "@ } # Windows PowerShell 5.1 writes a BOM for -Encoding UTF8, and neither the TOML # nor the JSON parser on the Codex side skips one. Write both files through # .NET with a BOM-less encoder instead. function Write-TextNoBom { param([string]$Path, [string]$Text) [System.IO.File]::WriteAllText($Path, $Text, (New-Object System.Text.UTF8Encoding($false))) } # Earlier versions stored the key in a user-scope environment variable. Codex # no longer reads it, and leaving it behind keeps a live credential visible to # every process the user starts. function Remove-LegacyEnvPlumbing { if ([System.Environment]::GetEnvironmentVariable("PRIMEROUTER_API_KEY", "User")) { [System.Environment]::SetEnvironmentVariable("PRIMEROUTER_API_KEY", $null, "User") Say-Ok "Removed the old user-scope PRIMEROUTER_API_KEY variable." } } # Writes Codex's own credential store. Same shape codex login --with-api-key # produces (AuthDotJson: OPENAI_API_KEY + auth_mode), which is what makes this # independent of the process environment. function Write-CodexAuth { param([string]$Key) if ((Test-Path -LiteralPath $CodexAuth) -and (Select-String -LiteralPath $CodexAuth -Pattern '"tokens"' -Quiet -ErrorAction SilentlyContinue)) { Say-Warn "An existing ChatGPT login was found in $CodexAuth." Say-Warn "Codex keeps only one credential, so PrimeRouter replaces it." Say-Warn "Restore it later with: codex login (backup kept alongside)" } Backup-IfExists -Path $CodexAuth Write-TextNoBom -Path $CodexAuth -Text @" { "OPENAI_API_KEY": "$Key", "auth_mode": "apikey" } "@ Say-Ok "Wrote $CodexAuth" } # --- End shared Codex provider-id adoption --- # --- 1. Node.js --- $needNode = $false if (-not (Get-Command node -ErrorAction SilentlyContinue)) { $needNode = $true } else { $major = [int]((node -v).TrimStart('v').Split('.')[0]) if ($major -lt 22) { $needNode = $true } } if ($needNode) { Say-Warn "Node.js v22+ is required to install @openai/codex." if (-not (Confirm-Action "Install Node.js LTS now?")) { Say-Die "Aborted. Install Node.js manually then re-run." } Invoke-Expression (Invoke-RestMethod -Uri "$InstallBase/nodejs.ps1") } # --- 2. @openai/codex --- if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { Say-Warn "Codex CLI (@openai/codex) is not on PATH." if (-not (Confirm-Action "Install it globally via npm now?")) { Say-Die "Aborted. Install @openai/codex manually then re-run." } $npmRegistryArgs = Get-NpmRegistryArgs npm install -g $npmRegistryArgs "@openai/codex" } # --- 3. API key --- # Pre-seeded by personalized installers; prompt only when absent. $PrKey = $env:PRIMEROUTER_API_KEY if ([string]::IsNullOrWhiteSpace($PrKey)) { $secure = Read-Host "Paste your PrimeRouter API key" -AsSecureString $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($secure) $PrKey = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($ptr) } if ([string]::IsNullOrWhiteSpace($PrKey)) { Say-Die "API key cannot be empty." } # The key is written into a JSON string literal below; anything needing escapes # is a paste accident, not a real PrimeRouter key. if ($PrKey -match '["\\]') { Say-Die "API key contains quotes or backslashes - re-copy it from the console." } # --- 4. Write config --- if (-not (Test-Path -LiteralPath $CodexDir)) { New-Item -ItemType Directory -Force -Path $CodexDir | Out-Null } $ProviderId = Resolve-ProviderId Backup-IfExists -Path $CodexConf # Here-strings carry no trailing newline, so join the sections explicitly # rather than concatenating and hoping - a glued "[model_providers]" and # "[model_providers.x]" is a TOML parse error, not a cosmetic slip. $sections = @( @" # Generated by PrimeRouter install script. # Custom provider points Codex (CLI) at PrimeRouter. # Top-level keys MUST stay above the [model_providers.*] table - TOML puts # anything below a table header inside that table, and Codex then silently # falls back to the default OpenAI provider. model_provider = "$ProviderId" model = "$PrModel" model_reasoning_effort = "high" disable_response_storage = true [model_providers] "@, (Get-ProviderTable $ProviderId) ) if ($ProviderId -ne "primerouter") { $sections += @" # The active id above is inherited from this machine so Codex keeps listing # sessions recorded before the switch - both tables point at PrimeRouter. # Prefer a clean id? Re-run with PRIMEROUTER_CODEX_PROVIDER_ID=primerouter # (old sessions drop out of ``codex resume``, the files themselves are # untouched). "@ $sections += (Get-ProviderTable "primerouter") } Write-TextNoBom -Path $CodexConf -Text (($sections -join "`n`n") + "`n") Say-Ok "Wrote $CodexConf (provider id: $ProviderId)" # --- Store the key in Codex's own credential store --- # auth.json instead of an env var: with env_key set, Codex hard-fails the # request when the variable is missing (it is not a fallback), which is what # broke users whose launch context did not inherit the variable. Write-CodexAuth -Key $PrKey # --- Retire the env-var plumbing earlier versions installed --- Remove-LegacyEnvPlumbing Say-Ok "Codex CLI is wired to $PrBaseUrl. Run: codex"