# PrimeRouter — Codex Desktop app install + configure (Windows). # Stores the key in %USERPROFILE%\.codex\auth.json (Codex's own credential # store) — the desktop app is a GUI process, so a variable set for the shell # was never a dependable channel — 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 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" # The Codex desktop app is distributed through the Microsoft Store. $StoreProductId = "9PLM9XGG6VKS" function Test-CodexAppInstalled { try { $pkg = Get-AppxPackage -Name "*OpenAI*Codex*" -ErrorAction SilentlyContinue return ($null -ne $pkg) } catch { return $false } } # --- 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. Codex Desktop app --- if (Test-CodexAppInstalled) { Say-Ok "Codex desktop app is already installed." } else { Say-Warn "The Codex desktop app is not installed." $installed = $false if (Get-Command winget -ErrorAction SilentlyContinue) { Say-Warn "Installing from the Microsoft Store via winget..." try { winget install --id $StoreProductId --source msstore ` --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -eq 0 -and (Test-CodexAppInstalled)) { $installed = $true } } catch { $installed = $false } } if (-not $installed) { Say-Warn "Opening the Microsoft Store page for Codex — click 'Install' there." Start-Process "ms-windows-store://pdp/?ProductId=$StoreProductId" Read-Host "After the Store install finishes, press Enter here to continue" | Out-Null if (-not (Test-CodexAppInstalled)) { Say-Die "Codex desktop app still not detected. Finish the Store install, then re-run this script." } } Say-Ok "Codex desktop app installed." } # --- 2. 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." } # --- 3. Write config (shared by the desktop app and the Codex CLI) --- 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 (desktop app and 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 desktop app is wired to $PrBaseUrl. Launch it from the Start menu." Say-Warn "If the app was already open, quit and reopen it so it picks up the new environment."