textEx.ps1

# Nabil Redmann - 2026-03-22
# License: MIT

<#
    .SYNOPSIS
    Generate text using the Pollinations AI API, for multiline texts.
 
    .DESCRIPTION
    Generate text based on the given prompt using the Pollinations AI API, by using the OpenAI compatible REST Endpoints.
 
    .PARAMETER content
    The prompt for the text.
    Piping into this script, will use this parameter.
    .PARAMETER prompt
    Alternative to content.
    The prompt for the text.
 
    .PARAMETER settings
    A hashtable of settings passed to the Pollinations AI API.
 
    .PARAMETER model
    The model to use for text generation.
 
    .PARAMETER assignedModelList
    The endpoint the model is from to use for audio generation (text or audio model list).
 
    .PARAMETER POLLINATIONSAI_API_KEY
    The API key to use for the Pollinations AI API.
    .PARAMETER key
    Alternative to POLLINATIONSAI_API_KEY.
    The API key to use for the Pollinations AI API.
 
    .PARAMETER colors
    Adds a string to the prompt to request ANSI formatting instead of Markdown.
    Can be set globally with $env:POLLINATIONSAIPS_COLORS=$true
 
    .PARAMETER out
    The local path to save the generated text.
     
    .PARAMETER save
    Will save to the system temp folder.
 
    .PARAMETER details
    Will return the details of the generated text (headers + content).
 
    .PARAMETER getSettingsDefault
    Get the default settings for the Pollinations AI API.
 
    .PaRAMETER listModels
    Get the list of available models for the Pollinations AI API.
 
    .EXAMPLE
    PS C:\> Get-PollinationsAiText -listModels
    PS C:\> Get-PollinationsAiText -content "a cat" -model "openai" -save
 
    List the available models, then generate an text based on the prompt "a cat" and save it.
 
    .EXAMPLE
    PS C:\> Get-PollinationsAiText "a cat" -set @{"system" = "just output a comma separated list of typical colors"}
 
    Generate an text based on the prompt "a cat" and set the system prompt to "just output a comma separated list of typical colors".
 
    .EXAMPLE
    PS C:\> $env:POLLINATIONSAI_API_KEY = "sk_..."
    PS C:\> $s = Get-PollinationsAiText -getSettingsDefault
    PS C:\> $s
        Name Value
        ---- -----
        system
        temperature 1
        seed 0
         
    PS C:\> $s.temperature = 2.0
    PS C:\> Get-PollinationsAiText -content "a cat" -settings $s -out acat.jpg
 
    .NOTES
    Performance -> set AssignedModelList to either 'text' or 'audio' to prevent 2 extra API calls for checking model lists
 
    .NOTES
    To get audio for designated models, restrict the output modalities to "audio", and use:
        -set @{"modalities" = "audio"}
 
    .NOTES
    Use -Debug to see the Write-Debug output
 
    TEST with httpie:
    https GET gen.pollinations.ai/text/describe%20a%20cat --verbose -A bearer -a sk_* model==nomnom
 
    .OUTPUTS
    The generated text content
    OR
    content and headers as @{ Headers; Content; Uri } using: -details
 
    Error:
        throws @{ StatusCode = <error code>; Message = <error message> }
#>

Function Get-PollinationsAiTextEx {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingCmdletAliases', '', Scope = 'Function', Target = '*')]
    [CmdletBinding(DefaultParameterSetName="None")]
    param (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ParameterSetName='None', Position=0, HelpMessage="Prompt for the text")]
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ParameterSetName='WithOut', Position=0, HelpMessage="Prompt for the text")]
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ParameterSetName='WithSave', Position=0, HelpMessage="Prompt for the text")]
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ParameterSetName='WithDetails', Position=0, HelpMessage="Prompt for the text")]
        [Alias("prompt")]
        $content,

        [hashtable]
        [Parameter(Mandatory=$false, ParameterSetName='None', HelpMessage="A hashtable of settings passed to the Pollinations AI API, see https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}")]
        [Parameter(Mandatory=$false, ParameterSetName='WithOut', HelpMessage="A hashtable of settings passed to the Pollinations AI API, see https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}")]
        [Parameter(Mandatory=$false, ParameterSetName='WithSave', HelpMessage="A hashtable of settings passed to the Pollinations AI API, see https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}")]
        [Parameter(Mandatory=$false, ParameterSetName='WithDetails', HelpMessage="A hashtable of settings passed to the Pollinations AI API, see https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}")]
        $settings = @{},
        
        [string]
        [Parameter(Mandatory=$false, ParameterSetName='None', HelpMessage="The model to use for text generation. Defaults to 'ztext'. See https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}.query.model")]
        [Parameter(Mandatory=$false, ParameterSetName='WithOut', HelpMessage="The model to use for text generation. Defaults to 'ztext'. See https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}.query.model")]
        [Parameter(Mandatory=$false, ParameterSetName='WithSave', HelpMessage="The model to use for text generation. Defaults to 'ztext'. See https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}.query.model")]
        [Parameter(Mandatory=$false, ParameterSetName='WithDetails', HelpMessage="The model to use for text generation. Defaults to 'ztext'. See https://enter.pollinations.ai/api/docs#tag/genpollinationsai/GET/text/{prompt}.query.model")]
        $model = "nova-fast",

        [string]
        [Parameter(Mandatory=$false, ParameterSetName='None')]
        [Parameter(Mandatory=$false, ParameterSetName='WithOut')]
        [Parameter(Mandatory=$false, ParameterSetName='WithSave')]
        [Parameter(Mandatory=$false, ParameterSetName='WithDetails')]
        $assignedModelList = "", # 'text' or 'audio' - set to prevent 2 extra API calls for checking model lists

        [string]
        [Parameter(Mandatory=$false, ParameterSetName='None')]
        [Parameter(Mandatory=$false, ParameterSetName='WithOut')]
        [Parameter(Mandatory=$false, ParameterSetName='WithSave')]
        [Parameter(Mandatory=$false, ParameterSetName='WithDetails')]
        [Alias("key")]
        $POLLINATIONSAI_API_KEY = $env:POLLINATIONSAI_API_KEY,
        
        [switch]
        [Parameter(Mandatory=$false, ParameterSetName='None')]
        [Parameter(Mandatory=$false, ParameterSetName='WithOut')]
        [Parameter(Mandatory=$false, ParameterSetName='WithSave')]
        [Parameter(Mandatory=$false, ParameterSetName='WithDetails')]
        [Alias("nocache")]
        $bypassCache = $false,  # this only bypasses the cloudflare cache, resulting in a newly generated response.

        [switch]
        [Parameter(Mandatory=$false, ParameterSetName='None')]
        [Parameter(Mandatory=$false, ParameterSetName='WithDetails')]
        [Alias("ansi")]
        [Alias("clicolors")]
        $colors = $(if ($null -ne $env:POLLINATIONSAIPS_COLORS) { [boolean]$env:POLLINATIONSAIPS_COLORS } else { $false }),

        [string]
        [Parameter(Mandatory=$true, ParameterSetName='WithOut')]
        $out = "",
        
        [switch]
        [Parameter(Mandatory=$true, ParameterSetName='WithSave')]
        $save = $false,                                                     # save a file with `-out <name>` or `-save` to save it with a provided name to the sys temp

        [switch]
        [Parameter(Mandatory=$true, ParameterSetName='WithDetails')]
        [Parameter(Mandatory=$false, ParameterSetName='WithOut')]
        [Parameter(Mandatory=$false, ParameterSetName='WithSave')]
        [Parameter(Mandatory=$false, ParameterSetName='GetModelsList')]
        $details = $false,

        # stand alone
        [switch]
        [Parameter(Mandatory=$true, ParameterSetName='GetSettingsDefault')]
        $getSettingsDefault = $false,

        # stand alone
        [switch]
        [Parameter(Mandatory=$true, ParameterSetName='GetModelsList')]
        $listModels = $false
    )

    begin {
        $contentIn = ""
    }

    process {
        if ($content -is [System.Array]) {
            $contentIn += ($content -join "`n")
        } elseif ($null -ne $content -and $content -isnot [string]) {
            $contentIn += $content.ToString()
        } else {
            $contentIn += $content + "`n"
        }
    }

    end {
        $content = $contentIn


        # ---------------------------------------------------------------


        $ANSI_FORMATTING = "Format the output with ANSI colors and ANSI formatting, directly for the console/terminal without a code fence (instead of using Markdown), and do not talk about it. "

        <#
        .LINK
        https://enter.pollinations.ai/api/docs#tag/%EF%B8%8F-text-generation/POST/v1/chat/completions
        #>

        $defaultSettingsByApi = @{
            seed = 0 # 0 == random, max == 9007199254740991, -1 == ?? is in the pollination docs's example
            temperature = 1.0
        }


        if ($getSettingsDefault) {
            return $defaultSettingsByApi
        }


        Function getList {
            $uris = @(
                @('text', "https://gen.pollinations.ai/text/models"),
                @('audio', "https://gen.pollinations.ai/audio/models")
                # @('image', "https://gen.pollinations.ai/image/models")
                # @('video', "https://gen.pollinations.ai/video/models")
            )

            $block = [scriptblock]{
                $Key, $Uri = $_
                try { $response = Invoke-WebRequest -Uri $Uri -Method Get -UseBasicParsing } catch { $response = $null }
                
                if ($null -ne $response) {
                    return $response.content | ConvertFrom-Json |? {$_.output_modalities -Contains "text"} |% {$_ | Add-Member -MemberType NoteProperty -Name 'ModelsList' -Value $Key; $_} |% {if ($null -eq $_.paid_only) {$_ | Add-Member -MemberType NoteProperty -Name 'paid_only' -Value $false}; $_} | select 'paid_only', * -ExcludeProperty 'is_specialized', 'tools' -ErrorAction SilentlyContinue
                }
            }

            if ( (Get-Command ForEach-Object).Parameters.ContainsKey('Parallel') ) { # PowerShell 7+
                $list = $uris |% -Parallel $block
            }
            else {
                $list = $uris |% $block
            }

            return $list | sort -Property name
        }

        if ($listModels -eq $true) {
            $list = getList

            if ($details) {
                return $list
            }
            else {
                return $list | Format-Table
            }
        }


        # ---------------------------------------------------------------


        if (-not $POLLINATIONSAI_API_KEY) { throw "⚠️ POLLINATIONSAI API KEY is missing! (-key or -POLLINATIONSAI_API_KEY or set `$env:POLLINATIONSAI_API_KEY=`"sk_...`")" }


        # ---------------------------------------------------------------


        #* we do not merge the defaults into this settings object by default, because the generated URL query would be longer then necessary
        $requestSettings = @{
            'model' = if (-not $model) { "nova-fast" } else { $model } # since this could be set to '

            # 'modalities[]' = "text" #! There seems to be bug with Pollinations where there is only 'text' as modality, Error: {"message":"feature 'modalities' is not currently supported"}}
        } + $settings

        # bypasses cloudflare cache
        if ($bypassCache) {
            $requestSettings['cacheBuster'] = [string](Get-Date).Ticks + (Get-Random)
        }

        $headers = @{
            'Authorization' = "Bearer $POLLINATIONSAI_API_KEY"
            'Content-Type' = "application/json"
        }

        if ($assignedModelList -eq "") {
            $assignedModelList = getList |? {$_.name -eq $model} | select -ExpandProperty ModelsList

            if ($assignedModelList -eq "") {
                throw [PSCustomObject]@{ Message = "Model unknown. Could not be found in the text or audio list of models." }
            }
        }

        #! SPECIAL FIX FOR OpenAI-Audio
        if ($model -eq "openai-audio") {
            $requestSettings.modalities = @(, "text")
        }

        $contentCombined = if ($Colors) { $ANSI_FORMATTING + "`n`n" + $content } else { $content }

        switch ($assignedModelList) {
            'text' {
                $requestUrl = "https://gen.pollinations.ai/v1/chat/completions"
                $requestSettings = @{
                    'messages' = @(
                        @{
                            'content' = $contentCombined
                            'role' = "user" # 'system'
                            'name' = ""     # ??? in documents, but missing description
                            'cache_control' = @{
                                'type' = "ephemeral"
                            }
                        }
                    )
                } + $requestSettings + $settings
            }
            'audio' {
                $requestUrl = "https://gen.pollinations.ai/v1/audio/speech"
                $requestSettings = @{
                    'input' = $contentCombined
                    'voice' = "alloy"  # only for openai-audio, see https://platform.openai.com/docs/guides/text-to-speech#voice-options
                    'response_format' = "mp3"

                } + $requestSettings + $settings
            }
            default {
                throw "Generating from other model lists then text/audio is not supported." 
            }
        }

        $requestSettingsBody = $requestSettings | ConvertTo-Json -Compress -Depth 100 # 5 is needed, but the src above might change in the future

        # check for PowerShell 7+
        $canSkip = (Get-Command Invoke-WebRequest).Parameters.ContainsKey('SkipHttpErrorCheck')

        if ($canSkip) {
            $response = Invoke-WebRequest -Uri $requestUrl -Method Post -Body $requestSettingsBody -Headers $headers -UseBasicParsing     -SkipHttpErrorCheck    # get the error message in the response
        }
        else {
            # Fallback for PowerShell 5.1 --> does only show the status code, since the response is dropped by Invoke-WebRequest
            try {
                $response = Invoke-WebRequest -Uri $requestUrl -Method Post -Body $requestSettingsBody -Headers $headers -UseBasicParsing     -ErrorAction Stop
            }
            catch {
                $response = @{ StatusCode = $_.Exception.Response.StatusCode.Value__; Message = $_.Exception.Response.StatusCode }
            }
        }



        # check for errors
        if ($response.StatusCode -ne 200) {
            $err = if ($response.content) {$response.content | ConvertFrom-Json |% { @{ StatusCode = $_.status; Message = $_.error.message} }} else { $response }
            Write-Error $err

            # set error code
            $global:LASTEXITCODE = $response.StatusCode
            throw [PSCustomObject]$err
        }

        $ret = ""


        # unwrap the response text
        #$response.content

        Function unwrapResponseText {
            param ($res)

            $resContent = $res.content | ConvertFrom-Json
            $r = $resContent.choices | select -last 1 | select -ExpandProperty message

            return $r.content # , $resContent.usage
        }

        $isContentBytes = $response -and $response.Content -and $response.Content.GetType().Name -eq "Byte[]"

        # save the text
        if ($out -ne "" -or $save -eq $true) {

            if ($out -eq "") {
                #* NOTE: 'Content-Disposition' is always missing on the text endpoint
                $targetFilename = $response.Headers["X-Request-ID"].Trim()
                if ($targetFilename -eq "") { $targetFilename = (Get-Date).ToString("yyyyMMddHHmmss") + "-" + (Get-Random) }
                
                # dir is temp dir
                $targetDir = [IO.Path]::GetTempPath()
                
                $filepath = [IO.Path]::Combine($targetDir, $targetFilename)
            }
            else {
                $filepath = [IO.Path]::Combine($PWD, $out)
            }
            
            if ($Null -eq (Split-Path $filepath -Leaf).Split(".")[1]) { #PWSH 6+ "" -eq (Split-Path $filepath -Leaf | Split-Path -Extension)
                # 'text/html' ... 'application/json' ...
                $type = $response.Headers["Content-Type"] -split ";" | select -First 1 |% { $_ -split "/"} | Select-Object -Last 1
                if ($type -eq "" -or $type -eq "plain") { $type = "txt" }
                $filepath += "." + $type
            }
            
            Write-Debug "Filepath: $filepath"
            
            if ($isContentBytes) {
                # save bytes (possibly audio)
                [IO.File]::WriteAllBytes($filepath, $response.Content)
            }
            else {
                # save the text
                unwrapResponseText($response) | Out-File -FilePath $filepath
            }

            $ret = $filepath
        }

        if ($details -eq $true) {
            $ret = if ($ret) { @{ FilePath = $ret } } else { @{} } #filename available
            $ret = $ret +  @{
                Headers = $response.Headers
                Content = if ($isContentBytes) { $response.Content } else { unwrapResponseText($response) }
            }
            if (-not $isContentBytes) { $ret += @{
                FullContent = $response.Content | ConvertFrom-Json
            }}
            if (-not $isContentBytes -and $colors) { $ret += @{
                FormattedContent = (unwrapResponseText($response) | ConvertFrom-AnsiEscapedString)
            }}
        }

        if ($ret) {
            return $ret
        }
        else {
            if ($isContentBytes) {
                return $response.Content
            } elseif ($colors) {
                return (unwrapResponseText($response) | ConvertFrom-AnsiEscapedString)
            } else {
                return unwrapResponseText($response)
            }
        }
    }

}