[CmdletBinding()] param( [ValidateSet('Menu', 'Configure', 'Status', 'Switch', 'Restore')] [string]$Action = 'Menu', [ValidateSet('primary', 'hongkong')] [string]$Route = 'primary' ) $script:NexaGWRoutes = @{ primary = 'https://api.nexagwapi.com/v1' hongkong = 'https://api.nexagw.org/v1' } function Resolve-NexaGWRoute { [CmdletBinding()] param([Parameter(Mandatory)][ValidateSet('primary', 'hongkong')][string]$Route) return $script:NexaGWRoutes[$Route] } function Write-NexaGWUtf8File { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Content ) $Parent = Split-Path -Parent $Path if ($Parent) { New-Item -ItemType Directory -Force -Path $Parent | Out-Null } $Utf8 = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Content, $Utf8) } function Add-NexaGWTextBlock { param( [Parameter(Mandatory)][ref]$Destination, [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines ) $List = $Destination.Value $Start = 0 $End = $Lines.Count - 1 while ($Start -le $End -and [string]::IsNullOrWhiteSpace($Lines[$Start])) { $Start++ } while ($End -ge $Start -and [string]::IsNullOrWhiteSpace($Lines[$End])) { $End-- } if ($Start -gt $End) { return } if ($List.Count -gt 0) { $List.Add('') } for ($Index = $Start; $Index -le $End; $Index++) { $List.Add($Lines[$Index]) } } function Update-NexaGWCodexConfig { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ConfigPath, [Parameter(Mandatory)][ValidateSet('primary', 'hongkong')][string]$Route ) $ResolvedUrl = Resolve-NexaGWRoute -Route $Route $Text = if (Test-Path -LiteralPath $ConfigPath) { [System.IO.File]::ReadAllText($ConfigPath) } else { '' } $Lines = if ($Text.Length -eq 0) { @() } else { $Text -split "`r?`n" } $RootLines = New-Object 'System.Collections.Generic.List[string]' $Sections = New-Object 'System.Collections.Generic.List[object]' $CurrentSection = $null $ManagedSectionCount = 0 foreach ($Line in $Lines) { $Trimmed = $Line.Trim() if ($Trimmed.StartsWith('[')) { $SingleTable = [regex]::Match($Trimmed, '^\[([^\[\]]+)\]\s*(?:#.*)?$') $ArrayTable = [regex]::Match($Trimmed, '^\[\[([^\[\]]+)\]\]\s*(?:#.*)?$') if (-not $SingleTable.Success -and -not $ArrayTable.Success) { throw "Malformed TOML table header: $Line" } $Name = if ($SingleTable.Success) { $SingleTable.Groups[1].Value.Trim() } else { $ArrayTable.Groups[1].Value.Trim() } if ($Name -eq 'model_providers.nexagw') { $ManagedSectionCount++ } $CurrentSection = [pscustomobject]@{ Name = $Name Lines = New-Object 'System.Collections.Generic.List[string]' } $CurrentSection.Lines.Add($Line) $Sections.Add($CurrentSection) continue } if ($null -eq $CurrentSection) { if ($Line -notmatch '^\s*(model|model_provider)\s*=') { $RootLines.Add($Line) } } else { $CurrentSection.Lines.Add($Line) } } if ($ManagedSectionCount -gt 1) { throw 'Duplicate [model_providers.nexagw] tables were found. Resolve the duplicate before running setup.' } $Output = New-Object 'System.Collections.Generic.List[string]' Add-NexaGWTextBlock -Destination ([ref]$Output) -Lines @( 'model = "gpt-5.6-sol"', 'model_provider = "nexagw"' ) Add-NexaGWTextBlock -Destination ([ref]$Output) -Lines ($RootLines.ToArray()) Add-NexaGWTextBlock -Destination ([ref]$Output) -Lines @( '[model_providers.nexagw]', 'name = "NexaGW"', ('base_url = "{0}"' -f $ResolvedUrl), 'env_key = "NEXAGW_API_KEY"', 'wire_api = "responses"' ) foreach ($Section in $Sections) { if ($Section.Name -ne 'model_providers.nexagw') { Add-NexaGWTextBlock -Destination ([ref]$Output) -Lines ($Section.Lines.ToArray()) } } $Result = ($Output -join "`r`n") + "`r`n" if (([regex]::Matches($Result, '(?m)^\[model_providers\.nexagw\]\s*$')).Count -ne 1) { throw 'Generated configuration does not contain exactly one NexaGW provider.' } if ($Result -notmatch ('(?m)^base_url\s*=\s*"{0}"\s*$' -f [regex]::Escape($ResolvedUrl))) { throw 'Generated configuration does not contain the selected route.' } $TempPath = "$ConfigPath.nexagw.tmp" try { Write-NexaGWUtf8File -Path $TempPath -Content $Result $Validation = [System.IO.File]::ReadAllText($TempPath) if (([regex]::Matches($Validation, '(?m)^\[model_providers\.nexagw\]\s*$')).Count -ne 1) { throw 'Temporary configuration validation failed.' } Move-Item -Force -LiteralPath $TempPath -Destination $ConfigPath } finally { if (Test-Path -LiteralPath $TempPath) { Remove-Item -Force -LiteralPath $TempPath } } } function Protect-NexaGWValue { param([Parameter(Mandatory)][AllowEmptyString()][string]$Value) $Secure = ConvertTo-SecureString -String $Value -AsPlainText -Force return ConvertFrom-SecureString -SecureString $Secure } function Unprotect-NexaGWValue { param([Parameter(Mandatory)][string]$ProtectedValue) $Secure = ConvertTo-SecureString -String $ProtectedValue $Pointer = [IntPtr]::Zero try { $Pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Pointer) } finally { if ($Pointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Pointer) } } } function Get-NexaGWEnvironmentSnapshot { param([Parameter(Mandatory)][ValidateSet('User', 'Process')][string]$EnvironmentTarget) $Value = [Environment]::GetEnvironmentVariable('NEXAGW_API_KEY', $EnvironmentTarget) return [ordered]@{ name = 'NEXAGW_API_KEY' wasPresent = ($null -ne $Value) protectedValue = if ($null -ne $Value) { Protect-NexaGWValue -Value $Value } else { $null } target = $EnvironmentTarget } } function Write-NexaGWManifest { param([Parameter(Mandatory)][string]$Directory) $ManifestPath = Join-Path $Directory 'SHA256SUMS' $Lines = Get-ChildItem -LiteralPath $Directory -File | Where-Object { $_.Name -ne 'SHA256SUMS' } | Sort-Object Name | ForEach-Object { '{0} {1}' -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name } Write-NexaGWUtf8File -Path $ManifestPath -Content (($Lines -join "`n") + "`n") } function Test-NexaGWBackupManifest { param([Parameter(Mandatory)][string]$BackupPath) $ManifestPath = Join-Path $BackupPath 'SHA256SUMS' if (-not (Test-Path -LiteralPath $ManifestPath)) { throw "Missing backup manifest: $ManifestPath" } foreach ($Line in [System.IO.File]::ReadAllLines($ManifestPath)) { if ([string]::IsNullOrWhiteSpace($Line)) { continue } if ($Line -notmatch '^([0-9a-f]{64}) ([^/\\]+)$') { throw "Invalid backup manifest line: $Line" } $Expected = $Matches[1] $Name = $Matches[2] $FilePath = Join-Path $BackupPath $Name if (-not (Test-Path -LiteralPath $FilePath)) { throw "Missing backup file: $Name" } $Actual = (Get-FileHash -LiteralPath $FilePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($Actual -ne $Expected) { throw "Backup checksum mismatch: $Name" } } } function New-NexaGWBackup { [CmdletBinding()] param( [Parameter(Mandatory)][string]$CodexHome, [Parameter(Mandatory)][string]$ConfigHome, [ValidateSet('User', 'Process')][string]$EnvironmentTarget = 'User' ) $BackupRoot = Join-Path $CodexHome 'nexagw-backups' New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null do { $Stamp = Get-Date -Format 'yyyyMMdd_HHmmss_fff' $BackupPath = Join-Path $BackupRoot $Stamp if (Test-Path -LiteralPath $BackupPath) { Start-Sleep -Milliseconds 2 } } while (Test-Path -LiteralPath $BackupPath) New-Item -ItemType Directory -Path $BackupPath | Out-Null $ConfigPath = Join-Path $CodexHome 'config.toml' if (Test-Path -LiteralPath $ConfigPath) { Copy-Item -LiteralPath $ConfigPath -Destination (Join-Path $BackupPath 'config.toml.before') } else { Write-NexaGWUtf8File -Path (Join-Path $BackupPath 'config.toml.missing') -Content "missing`n" } $Environment = Get-NexaGWEnvironmentSnapshot -EnvironmentTarget $EnvironmentTarget $Operation = [ordered]@{ type = 'backup' createdAt = (Get-Date).ToString('o') codexHome = $CodexHome configHome = $ConfigHome } Write-NexaGWUtf8File -Path (Join-Path $BackupPath 'environment.json') -Content (($Environment | ConvertTo-Json -Depth 4) + "`n") Write-NexaGWUtf8File -Path (Join-Path $BackupPath 'operation.json') -Content (($Operation | ConvertTo-Json -Depth 4) + "`n") Write-NexaGWManifest -Directory $BackupPath return $BackupPath } function Restore-NexaGWBackup { [CmdletBinding()] param( [Parameter(Mandatory)][string]$BackupPath, [Parameter(Mandatory)][string]$CodexHome, [Parameter(Mandatory)][string]$ConfigHome, [ValidateSet('User', 'Process')][string]$EnvironmentTarget = 'User' ) $ResolvedBackup = (Resolve-Path -LiteralPath $BackupPath).Path Test-NexaGWBackupManifest -BackupPath $ResolvedBackup $SafetyBackup = New-NexaGWBackup -CodexHome $CodexHome -ConfigHome $ConfigHome -EnvironmentTarget $EnvironmentTarget $ConfigPath = Join-Path $CodexHome 'config.toml' $BeforePath = Join-Path $ResolvedBackup 'config.toml.before' $MissingPath = Join-Path $ResolvedBackup 'config.toml.missing' if (Test-Path -LiteralPath $BeforePath) { Copy-Item -Force -LiteralPath $BeforePath -Destination $ConfigPath } elseif (Test-Path -LiteralPath $MissingPath) { if (Test-Path -LiteralPath $ConfigPath) { Remove-Item -Force -LiteralPath $ConfigPath } } else { throw 'Backup contains neither config.toml.before nor config.toml.missing.' } $Environment = Get-Content -Raw -LiteralPath (Join-Path $ResolvedBackup 'environment.json') | ConvertFrom-Json $RestoredValue = $null if ($Environment.wasPresent) { $RestoredValue = Unprotect-NexaGWValue -ProtectedValue $Environment.protectedValue } try { [Environment]::SetEnvironmentVariable('NEXAGW_API_KEY', $RestoredValue, $EnvironmentTarget) if ($EnvironmentTarget -eq 'User') { $env:NEXAGW_API_KEY = $RestoredValue } } finally { $RestoredValue = $null } return [pscustomobject]@{ RestoredFrom = $ResolvedBackup SafetyBackup = $SafetyBackup } } function Set-NexaGWApiKey { [CmdletBinding()] param( [Parameter(Mandatory)][Security.SecureString]$SecureKey, [ValidateSet('User', 'Process')][string]$EnvironmentTarget = 'User' ) $Pointer = [IntPtr]::Zero $PlainKey = $null try { $Pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureKey) $PlainKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Pointer) if ([string]::IsNullOrWhiteSpace($PlainKey)) { throw 'API Key cannot be empty.' } [Environment]::SetEnvironmentVariable('NEXAGW_API_KEY', $PlainKey, $EnvironmentTarget) $env:NEXAGW_API_KEY = $PlainKey } finally { $PlainKey = $null if ($Pointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Pointer) } } } function Test-NexaGWConfig { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ConfigPath, [ValidateSet('User', 'Process')][string]$EnvironmentTarget = 'User' ) $Text = if (Test-Path -LiteralPath $ConfigPath) { [System.IO.File]::ReadAllText($ConfigPath) } else { '' } $ProviderCount = ([regex]::Matches($Text, '(?m)^\[model_providers\.nexagw\]\s*$')).Count $BaseUrlMatch = [regex]::Match($Text, '(?m)^base_url\s*=\s*"(https://api\.nexagw(?:api\.com|\.org)/v1)"\s*$') $KeyValue = [Environment]::GetEnvironmentVariable('NEXAGW_API_KEY', $EnvironmentTarget) return [pscustomobject]@{ ConfigPath = $ConfigPath ConfigExists = (Test-Path -LiteralPath $ConfigPath) ProviderValid = ($ProviderCount -eq 1) BaseUrl = if ($BaseUrlMatch.Success) { $BaseUrlMatch.Groups[1].Value } else { $null } KeyConfigured = -not [string]::IsNullOrWhiteSpace($KeyValue) } } function Confirm-NexaGWMutation { param([Parameter(Mandatory)][string]$Message) $Answer = Read-Host "$Message [y/N]" return $Answer -match '^(?i:y|yes)$' } function Invoke-NexaGWSetup { [CmdletBinding()] param( [ValidateSet('Menu', 'Configure', 'Status', 'Switch', 'Restore')][string]$Action = 'Menu', [ValidateSet('primary', 'hongkong')][string]$Route = 'primary' ) $UserHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME } $CodexHome = Join-Path $UserHome '.codex' $ConfigHome = Join-Path $UserHome '.config\nexagw' $ConfigPath = Join-Path $CodexHome 'config.toml' if ($Action -eq 'Menu') { Write-Host 'NexaGW Codex Setup' Write-Host '1. Configure 2. Status 3. Switch route 4. Restore 0. Exit' switch (Read-Host 'Choose an action') { '1' { $Action = 'Configure' } '2' { $Action = 'Status' } '3' { $Action = 'Switch' } '4' { $Action = 'Restore' } default { return } } } if ($Action -eq 'Status') { Test-NexaGWConfig -ConfigPath $ConfigPath | Format-List return } if (-not (Confirm-NexaGWMutation -Message "Run $Action now?")) { Write-Host 'Cancelled. No changes were made.' return } if ($Action -eq 'Restore') { $BackupRoot = Join-Path $CodexHome 'nexagw-backups' $Backups = @(Get-ChildItem -LiteralPath $BackupRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending) if ($Backups.Count -eq 0) { throw "No backups found: $BackupRoot" } for ($Index = 0; $Index -lt $Backups.Count; $Index++) { Write-Host "[$($Index + 1)] $($Backups[$Index].Name)" } $Selection = Read-Host 'Choose a backup number' $Number = 0 if (-not [int]::TryParse($Selection, [ref]$Number) -or $Number -lt 1 -or $Number -gt $Backups.Count) { throw 'Invalid backup number.' } $Result = Restore-NexaGWBackup -BackupPath $Backups[$Number - 1].FullName -CodexHome $CodexHome -ConfigHome $ConfigHome Write-Host "Restored from: $($Result.RestoredFrom)" Write-Host "Safety backup: $($Result.SafetyBackup)" return } $Backup = New-NexaGWBackup -CodexHome $CodexHome -ConfigHome $ConfigHome if ($Action -eq 'Configure') { $SecureKey = Read-Host 'Enter the NexaGW API Key (input is hidden)' -AsSecureString Set-NexaGWApiKey -SecureKey $SecureKey } Update-NexaGWCodexConfig -ConfigPath $ConfigPath -Route $Route $Status = Test-NexaGWConfig -ConfigPath $ConfigPath Write-Host "Configured route: $($Status.BaseUrl)" Write-Host "Backup: $Backup" Write-Host "Rollback: run this tool again, choose Restore, then select $([IO.Path]::GetFileName($Backup))" } if ($MyInvocation.InvocationName -ne '.') { try { Invoke-NexaGWSetup -Action $Action -Route $Route } catch { Write-Error $_ exit 1 } }