Skip to content

v0.4.0-preview0008

Pre-release
Pre-release

Choose a tag to compare

@raandree raandree released this 19 Aug 10:35

[v0.4.0-preview0008]

Added

  • Register-ShpMcpServer, Get-ShpMcpServer and Unregister-ShpMcpServer
    attach MCP (Model Context Protocol) servers so their tools are offered to the
    model alongside the built-ins and any registered user tools. Opt out for one
    call with Invoke-Shp -DisableMcp; nothing is offered until you attach a
    server, so the default posture is unchanged.
    Both protocol eras are supported over stdio. Verified from the
    specification rather than from memory, which changed the design: the current
    revision 2026-07-28 removed the initialize handshake entirely - a modern
    request is stateless and carries its protocol version and client capabilities
    in _meta, with server/discover as a mandatory RPC - while nearly every
    server in the field still expects the handshake. The client therefore probes
    with server/discover and falls back to initialize on any other error or a
    timeout, never keyed to one error code. Both eras were negotiated live, and
    the real Azure MCP Server 2.0.5 turned out to be a legacy server - which
    settles whether supporting both was worth it.
    See specs/021-mcp-server-support.md.
  • Tool names are namespaced mcp_<alias>_<tool> using the alias you chose,
    not the server's self-reported name, which the protocol says nothing
    verifies. The sanitiser is written to a measured constraint, not a guessed
    one: the Copilot endpoint enforces ^[a-zA-Z0-9_-]{1,128}$, so a dot - which
    MCP explicitly permits in a tool name - becomes _. This matters because a
    rejected name identifies the offending tool only by its index in the
    request, and the resulting 400 is masked by the chat-to-responses fallback,
    surfacing as "model ... does not support Responses API" - a true statement
    about an entirely different problem.
  • Get-ShpTool now lists MCP tools next to user tools, with a new Origin
    column (User or Mcp) and the contributing Server. Invoke-Shp reports
    McpEnabled, McpServersAvailable, McpToolsAvailable and McpToolsCalled,
    and an MCP call emits the same ToolCall progress record as every other tool,
    so a host renders it identically.

Security

  • An MCP server is a third-party process running with your privileges, and
    there is no sandbox.
    Its tool names and descriptions are untrusted input the
    model reads on every round-trip, and its results are untrusted content. Three
    controls follow, each stated with its limits:
    • Nothing is discovered. No scan of the working directory, .vscode or a
      user profile. Register-ShpMcpServer -Path reads a file you name. A
      configuration file is a command line, and a command line is arbitrary code.
    • The tool list is frozen at registration. The client opens no
      subscriptions/listen stream, so it receives no
      notifications/tools/list_changed and a server cannot add or alter tools
      after you approved them. Refreshing is an explicit -Force. This does not
      stop a server changing its behaviour.
    • The child environment is built, not inherited. Unlike run_command,
      which deliberately inherits the whole block for compatibility, an MCP child
      starts from a minimal base plus exactly the variables you name in
      -Environment, so an ambient $env: credential is not handed to somebody
      else's process.
  • Set-ShpToolPolicy cannot gate an MCP call, and this is stated rather than
    implied.
    Its rules match resolved filesystem paths and leading command
    tokens; a tool call has neither. Demonstrated in one live Turn under
    Read(<repo>/**): the built-in read_file was denied with a reason and the
    MCP tool call ran. A policy that scopes read_file to one directory does
    nothing about an attached filesystem server. Reduce reach at attachment
    instead, with -ToolName.
  • The injection path was measured, not asserted. With a hostile instruction in a
    tool description only, the model read a decoy credentials file and passed
    its contents to the third-party server as a tool argument - the server's own
    log confirms receipt. The same run with -DisableFileAccess read nothing and
    leaked nothing.
  • A configuration entry asking for sandboxEnabled warns and still starts:
    a configuration written for a sandboxing host is exactly the one you want to
    reuse. The gap is surfaced twice rather than being fatal once - a warning that
    says the server is starting unsandboxed, plus SandboxRequested on the server
    record, because a warning scrolls away and a property does not. An entry
    carrying an unresolved ${...} variable is refused, because starting the
    literal text would run a different command than the file describes.
  • Get-ShpMcpServer reports environment variable names only, never values.

Known limits

  • stdio transport only. Streamable HTTP is deferred with the MCP Authorization
    framework; a half-authorised HTTP client would be worse than none.

  • Invoke-ShpBatch does not use attached servers and warns once. A worker
    runspace inherits nothing, so replaying an attachment would start one copy of
    every server per worker.

  • A server that exits is marked Faulted rather than restarted automatically.
    The specification says a client SHOULD restart one; automatic respawn of
    third-party code inside an unattended loop turns one crash into a crash loop
    nobody is watching. Re-attach with Register-ShpMcpServer -Force.

  • Resources, prompts, sampling, elicitation and roots are out of scope for this
    version. Client capabilities are declared empty, so a modern server needing
    one gets a protocol error naming it rather than hanging.

  • The cached OAuth token is now protected at rest, closing open decision #5.
    On Windows it is DPAPI-encrypted for your account; on every platform the file
    is restricted to the current user. Measured before the change, the real file
    was 40 bytes of clear text with the profile's inherited ACL, readable by
    BUILTIN\Administrators as well as by you.
    The file is self-describing - SHPv1:DPAPI:... or SHPv1:NONE:... - and
    Initialize-Shp reports which protection it applied, because a scheme that
    silently degrades to clear text is worse than clear text. On Linux and macOS
    there is no DPAPI equivalent without a dependency, so the scheme is NONE
    and file permissions (mode 600) are the only control; that is stated in the
    file and on screen rather than implied.
    No runtime dependency was added - ConvertTo-SecureString is built in.
    SecretManagement was considered and rejected: SecretStore prompts to unlock,
    which would break every unattended run, and configuring it not to prompt
    reduces it to file permissions while costing this module its empty dependency
    list. This buys protection against another principal on the machine; it does
    not protect against code running as you, and no candidate scheme would.
    See specs/020-encrypted-token-storage.md.

  • An existing clear-text token file keeps working and is upgraded in place by
    Initialize-Shp without re-authenticating
    , so nobody needs a browser just
    to gain protection. A protected file that cannot be decrypted - typically one
    copied from another machine or account - throws with an actionable message
    naming Initialize-Shp -Force, rather than sending a ciphertext to the
    service as a bearer token.

  • Set-ShpToolPolicy, Get-ShpToolPolicy and Clear-ShpToolPolicy scope what
    the unsandboxed file and shell tools may reach, so an unattended run can be
    given the access it needs instead of the caller's entire filesystem and shell.
    Rules are written Read(./src/**), Write(./out/**), Shell(git status),
    with a leading ! to deny; any matching deny beats every matching allow.
    Nothing changes until you set a policy - and once you do, the model is
    denied by default. Refusals are reported on the result as ToolCallsDenied,
    and the policy travels into every Invoke-ShpBatch worker, because a worker
    inherits no module state and would otherwise be the one unguarded path.
    This closes a real gap rather than a symmetry: -Confirm is interactive only
    so an unattended run never prompts, and -DisableFileAccess /
    -DisableTerminal are all-or-nothing, so the only safe unattended setting was
    "all tools off".
    See specs/019-tool-access-policy.md.

  • Paths are matched on the absolute, link-resolved path rather than the
    string the model supplied, so neither a .. segment nor a directory junction
    walks out of an allowed root, and patterns are anchored so a rule for out
    cannot match outsider. run_command is matched on whole leading tokens and
    refuses any shell metacharacter whatever the rules say, because
    git status; curl ... would otherwise pass a rule that only ever meant
    git status. A Shell rule constrains which program runs, not what it does;
    the spec states that limit and the others plainly.

  • Invoke-Shp -RetryDelaySec, and -TimeoutSec, -MaxRetryCount,
    -RetryDelaySec and -NetworkOutageToleranceSec on Get-ShpModel and
    Request-ShpEmbedding. All four now resolve identically everywhere - explicit
    parameter, then Set-ShpContext, then the built-in default - and apply to
    every request the module makes, including the OAuth-to-session token
    exchange, /models and embeddings. Those three previously called the retry
    wrapper with the module's own defaults, so Set-ShpContext -TimeoutSec 10 -MaxRetryCount 0 silently did not apply to them; and Invoke-Shp resolved
    its options after the token exchange had already run, so an explicit
    -TimeoutSec never reached the one request that gates every other one. There
    is deliberately no exemption for the auth handshake: the exchange is cached,
    so -MaxRetryCount 0 costs at most one un-retried attempt per session, and
    outage tolerance is a separate option, so a dropped connection during auth is
    still ridden out.

  • Compress-ShpChat drops the oldest exchanges from the running session
    conversation so a session that has outgrown the model's context window becomes
    usable again without discarding it. Until now the only ways out were
    Clear-ShpChat and a stateless -History call, and both throw the whole
    conversation away. Invoke-Shp writes the conversation back only when a call
    succeeds, so a refusal leaves it pinned and every later call is refused
    identically - measured at 0 successes in 108 retries. Measured against the
    live service, dropping the single oldest exchange was enough to restore a
    pinned session, so discarding all of it was never necessary. The first
    exchange (usually the task definition) and the newest exchange are kept,
    exchanges are dropped as whole user/assistant pairs, -WhatIf reports the
    plan without changing anything, and the returned report says exactly what
    went. Conversation turns are never elided automatically: a tool result is
    scaffolding the model produced for itself, but a user turn is something the
    user said, and a model answering from a silently truncated history can
    confidently contradict it.
    See specs/018-conversation-history-overflow.md.

  • Invoke-Shp now warns before sending once the context guard has elided
    every tool result it may and the conversation is still over budget, naming
    Compress-ShpChat. It is phrased as a fact about the guard rather than a
    prediction about the service, because ConvertTo-ShpTokenCount was measured
    at +30% / -12% against the service's own token count and is not accurate
    enough to refuse a call on. The warning fires once per turn, not once per tool
    iteration.

  • The context-window guard now sizes itself from the model in use. Left unset,
    -MaxContextWindowTokens resolves in four steps - the parameter, then
    Set-ShpContext -MaxContextWindowTokens, then the model's own advertised
    limits, then the built-in 900000 - and the resolved figure and the step that
    produced it are reported on the result as ContextBudget and
    ContextBudgetSource. The third step is not simply the advertised context
    window: that figure covers prompt plus completion, so the model's output
    allowance is reserved first and a 10% margin taken from what remains.
    Measured against the live service, claude-haiku-4.5 advertises a 200000
    window with a 64000 output cap and refuses a prompt at 136000 - exactly
    200000 - 64000 - so a margin on the advertised window alone would still have
    missed it. No advertised pair on offer resolves above 900000, so this can only
    ever tighten an existing caller's guard, never loosen it.
    See specs/017-context-window-budget-from-model.md.

  • Get-ShpModel records each model's advertised context window and output cap
    in a session cache as a side effect. That cache is what lets the guard resolve
    a real window with no request of its own: a turn is a loop, so consulting
    /models per turn would add a round-trip to calls that are otherwise local.
    Until something fills it - Get-ShpModel, or Get-ShpModelName, which calls
    it - the guard uses the fallback, which is no model's real window and is too
    permissive for 22 of the 36 models that advertise one. Run Get-ShpModel once
    per session to fix that. Invoke-ShpBatch copies the cache to every worker,
    and Initialize-Shp discards it on re-auth.

  • A model that is absent from a model list that was fetched now warns once per
    model per session that the guard is running on the fallback, in the same
    spirit as Priced / PriceTableKey making an unpriced call observable. A
    cache that has simply never been populated - the default state of every
    session - stays quiet.

  • Get-ShpUsage now records calls that failed, not only the ones that
    succeeded. A failed call carries Success $false and the failure message on
    Error, plus whatever spend its completed round-trips had already incurred.
    Both halves of that matter: a success rate computed from the old log was 100%
    by construction, because only successes were in it; and a turn is a loop of
    billable round-trips, so a turn refused on its third round-trip really was
    charged for the first two and reported nothing. Only a call that reached the
    API is recorded - a parameter combination rejected before any request was
    never a call. Invoke-ShpBatch inherits this, so a failed batch item now
    shows up in Get-ShpUsage too.
    See specs/016-failed-call-usage-accounting.md.

  • Get-ShpUsage -Summary gained Succeeded, Failed, TotalDurationMs,
    MeanDurationMs, FirstCall, LastCall and ElapsedMs, and the ByModel
    breakdown gained Succeeded, Failed and DurationMs. ElapsedMs is
    wall-clock between the first and last call and is deliberately not the sum
    of DurationMs: under Invoke-ShpBatch the calls overlap, so the sum can far
    exceed the elapsed time and the ratio between them is the speed-up the batch
    bought.

  • Get-ShpUsage -Since and -Before filter by time window, so one phase of a
    run can be summarised without clearing the log between phases. Both apply to
    the records and to -Summary. There is deliberately no -GroupBy:
    Get-ShpUsage returns the records, so Group-Object already groups by any
    field, and ByModel is pre-aggregated only because that split is the common
    case.

  • Invoke-ShpBatch runs many independent prompts concurrently and returns one
    ShellPilot.BatchResult per input, carrying the answer, that item's usage and
    cost, and - when the call failed - the error. -ThrottleLimit bounds how many
    calls are in flight (default 4, deliberately conservative), and prompts can be
    piped in as plain strings or as objects with Prompt and an optional Id.
    Three guarantees are the point of it. Every item is stateless: a batch never
    reads or writes the session conversation, so it cannot reproduce the
    accumulation that makes a serial loop of Invoke-Shp calls grow until the
    model refuses it with model_max_prompt_tokens_exceeded. Failures are
    isolated: one failed call never aborts the batch and nothing is written to the
    error stream, because an error raised from a worker obeys the caller's
    $ErrorActionPreference and would destroy every result under Stop; check
    Success and Error on the results, and a single summary warning names how
    many did not complete. And identity is carried: results arrive in completion
    order, so every one has Index, Id and the original InputObject.
    -MaxBatchBudgetUSD caps the whole run as a gate on dispatch - calls already
    in flight are never cancelled. Streaming, ask_user and progress events are
    off for every item, for reasons the help gives. The model, sampling
    parameters, -ResponseFormat / -JsonSchema, -SkillPath,
    -InstructionRoot, the isolation switches and the connection options are all
    forwarded, and per-item usage is merged into Get-ShpUsage.
    See specs/015-batch-execution.md.

  • Invoke-Shp -MaxContextWindowTokens and Set-ShpContext -MaxContextWindowTokens set the token budget above which a turn elides its
    oldest tool results, with the usual precedence of explicit parameter, then
    session context, then the built-in default. The built-in 900000 is a fallback
    rather than any model's real window - claude-haiku-4.5 is 136000 - so the
    guard could never fire for a smaller model; set this from the model's own
    MaxContextWindowTokens (see Get-ShpModel) to make it fire when it should.
    0 disables the guard. The default is unchanged, so existing calls behave
    exactly as before. Note this bounds tool results only: the session
    conversation is never elided, so a long loop of calls still needs
    Clear-ShpChat or -History.

  • Invoke-Shp now explains a rejection it cannot recover from. A service reply
    of model_max_prompt_tokens_exceeded is emitted as a warning naming the real
    cause - every -Prompt call continues the session conversation, so a loop of
    calls grows until it no longer fits - and the two remedies. Left unexplained
    this reads as a bare 400 and gets mistaken for rate limiting.

  • Invoke-Shp -Temperature, -TopP and -Seed control the model's sampling,
    so a call that has to be reproducible (grading or judging in an evaluation
    harness) can pin itself with -Temperature 0 and a variance measurement can
    fix its operating point instead of inheriting the backend default. Each field
    is omitted from the request body entirely when the parameter is not passed, so
    existing calls are unchanged. -Temperature is validated against 0..2 and
    -TopP against 0..1 before the request is sent, and a model that rejects a
    field fails the call rather than having the field silently dropped.
    The values used are reported on the result as Temperature, TopP and
    Seed. See specs/014-sampling-parameters.md.

  • Invoke-Shp and Get-ShpCostEstimate results carry Priced and
    PriceTableKey, so a call the price table cannot cost is no longer
    indistinguishable from a free one. PriceTableKey stays populated even when
    nothing matched, naming the key that was looked up and missed, and the first
    call for an unpriced model warns once per session rather than once per tool
    iteration. CostUSD and Credits are unchanged and still null - never 0 -
    when no rate is found. Priced is also recorded on each Get-ShpUsage entry.

  • Resolve-ShpError explains the last error in the session and suggests a fix.
    It takes an error record ($Error[0] by default, or from the pipeline), sends
    the message, exception type, category, target, failing command line and script
    stack trace to the model, and returns the usual Invoke-Shp result. Every
    tool is disabled unless -EnableTools is passed, so diagnosing an error
    cannot touch the machine.

  • Invoke-Shp supports ShouldProcess. -WhatIf dry-runs a whole agent turn -
    the model still plans and calls tools, but write_file, create_directory,
    run_command and user-registered tools are skipped and told they were not
    approved - and -Confirm prompts before each of those calls. Default
    behaviour is unchanged.

  • Invoke-Shp -MaxBudgetUSD stops the tool-calling loop once the turn's
    estimated spend passes the cap, and the result carries a new BudgetExceeded
    flag.

  • Invoke-Shp -AppendSystemPrompt adds inline system instructions in either
    parameter set, so a file-driven system prompt can still be topped up for a
    single call.

  • Invoke-Shp -AllowPrivateNetwork opts the fetch_url tool back in to
    loopback, link-local and private addresses.

  • Start-ShpChat gained the /models, /history, /retry and /usage
    commands. /retry drops the last exchange and resends the previous prompt.

  • The price table supports a long-context tier. An entry may carry a
    LongContext block with a Threshold in input tokens plus its own rates, and
    the cost breakdown now reports Tier and TiersUsed.

Changed

  • Set-ShpContext -TimeoutSec's documented built-in default was wrong. The
    help said 100 seconds; the module variable holding it was never read and the
    real default is 0, meaning no explicit timeout - the shared HttpClient is
    built with an infinite timeout so a long streamed turn is not cut off
    mid-response. The documentation is corrected and the dead variable removed. No
    behaviour changed, because the 100 had never applied to anything.

  • The model_max_prompt_tokens_exceeded warning now explains that a failed call
    is not written back, so the conversation stays pinned and every retry fails
    identically, and it names Compress-ShpChat alongside Clear-ShpChat and
    -History.

  • Get-ShpUsage -Summary's Calls and CostUSD will report different
    numbers for a session in which something failed.
    Calls now counts calls
    attempted; it previously counted records, and only successes were recorded,
    so it meant "calls that succeeded". Read Succeeded for the old number.
    CostUSD now includes the spend of turns that failed after one or more
    billable round-trips, which was previously dropped. Both are corrections
    rather than regressions - that money was really spent, and those calls were
    really made - but a caller reading Calls as a success count must move to
    Succeeded. Nothing changes for a session in which nothing failed.

  • The HTTP retry backoff is now jittered: half the exponential delay plus a
    random amount up to the other half. A purely deterministic backoff
    synchronises under concurrency - several Invoke-ShpBatch workers refused by
    the same 429 would sleep identical durations and re-fire together, recreating
    the burst that caused the refusal. A RetryDelaySec of 0 still yields
    exactly 0, so no existing call path changes.

Fixed

  • A long Turn no longer dies with 401 IDE token expired. A tool-calling
    Turn resolved its short-lived Copilot session token once, before the loop, and
    then reused that one credential for every iteration - so an agentic Turn that
    ran longer than the token's lifetime failed part-way through (observed at
    iteration 41) with IDE token expired: unauthorized: token expired. The
    sign-in was never the problem, and resending the prompt was not a recovery: it
    started a brand-new Turn at iteration 1 and threw away all the completed work.
    Invoke-Shp now re-resolves the session token before every iteration, which
    costs nothing until it is needed because a still-valid token is served from
    the in-memory cache without a round-trip. A 401 that still slips through is
    recovered once by exchanging a fresh token and retrying the same iteration -
    the answer is not duplicated, the iteration counter does not advance, and
    usage is not double-counted. A revoked or unauthorized GitHub OAuth token
    still fails, once, with a message naming Initialize-Shp, and an alternative
    backend authenticating with your own API key never has its Authorization
    header rewritten.

  • The session-token safety margin is now 300 seconds instead of 60, so a token
    is never handed to a request it cannot outlive. A single tool iteration can
    take minutes for a reasoning model working through a large tool result, so a
    token with 61 seconds left used to be served and then die on the next request.
    The cost is at most one extra token exchange per five minutes of an otherwise
    cached session.

  • run_command now runs the command it was given. Every unescaped double
    quote was being stripped before the child shell saw it, so the model's
    Write-Output "set to $env:X" ran as four bare arguments and
    --pretty=format:"%h %s" reached git as two. The command line was handed to
    Start-Process -ArgumentList as one array element; PowerShell joins that array
    into a single string, and the native argument parser then eats the quotes. It
    failed plausibly - the first example returned exit code 0 with output that
    looks like output - and the returned envelope echoed the command that was sent
    rather than the one that ran, so every transcript and CommandsRun entry
    recorded a command that never executed. The child is now started through
    System.Diagnostics.ProcessStartInfo with an ArgumentList, which quotes each
    element the way the platform requires, so the command is passed through
    verbatim and what the envelope reports is what ran. Single-quoted commands,
    the JSON envelope, MaxChars capping, the timeout envelope, the process-tree
    kill, the default working directory and the workingDirectory override are all
    unchanged, as is the environment the child inherits.

  • Streamed Invoke-Shp requests now use the same retry wrapper as buffered
    requests. HTTP 429/5xx responses are bounded by MaxRetryCount, and a true
    no-response transport failure uses NetworkOutageToleranceSec. The classifier
    reads the streaming sender's structured StatusCode before its bare
    HttpRequestException type, so a permanent 400 fails after one attempt instead
    of burning the network-outage budget. If reading an error body itself fails,
    the known status is preserved and both response and request are still disposed.

  • Invoke-Shp -History @() now genuinely starts from nothing. -History is
    documented as stateless, but an empty array is falsy and the check tested
    truthiness, so an explicitly empty history silently fell through to seeding
    the call from the session conversation - the opposite of what was asked for.
    Binding is now the test, matching the module's rule for every other optional
    value whose type has a meaningful default.

  • The streaming sender now carries the same structured error as the buffered
    one. Streaming is the Invoke-Shp default, so this was the common path on
    which a caller still had to match substrings in an exception message. A
    non-success status from Invoke-ShpStreamRequest now raises an ErrorRecord
    with the body on ErrorDetails.Message and a ShellPilot.HttpErrorDetail on
    TargetObject, which is also the only programmatic home the status has on
    that path - HttpRequestException carries no response. The exception type and
    the message wording are unchanged.

  • A failed buffered request now hands the service's error to the caller as
    data instead of only as text. Invoke-ShpHttpRequest raises a built error
    record rather than a bare exception, so $_.ErrorDetails.Message carries the
    response body the way Invoke-RestMethod does, and $_.TargetObject carries a
    ShellPilot.HttpErrorDetail with StatusCode, the service's own ErrorCode
    and Param, its Message, the whole raw Body and the RequestUri - so a
    script can branch on the error code without matching substrings in an
    exception string. Invoke-Shp's own catch block has always read
    $_.ErrorDetails.Message first, a member that until now was never populated
    and silently fell through on every failure. The exception object and the live
    response it carries are unchanged, so the 429/5xx retry and the network-outage
    budget classify a failure exactly as before, and the failure is now identified
    by the stable ShpHttpRequestFailed,Invoke-ShpHttpRequest error id instead of
    by an error id that repeated the whole message. Because ErrorDetails.Message
    replaces the record's display text, it is capped like the exception message;
    TargetObject.Body keeps the body whole. This covers every buffered call -
    Invoke-Shp -DisableStreaming, every /responses turn, /models, the token
    exchange and embeddings; streamed requests expose the same contract through
    their sender.

  • The streaming sender no longer quotes an unbounded error body. A non-success
    status from Invoke-ShpStreamRequest put the service's whole response into the
    exception message, so a 5xx from an intermediate proxy could turn an entire
    HTML page into an error message. It is now capped at the same 2000 characters
    with the same ...[truncated, original N chars] marker as the buffered
    sender. The wording is deliberately unchanged: that exception carries no
    response, so its URI and status exist only in the text.

  • A failed non-streaming request now reports what the service objected to. The
    buffered sender read the error response body and then discarded it, so a
    rejected request surfaced only as
    Response status code does not indicate success: 400 (Bad Request). The
    service's own explanation is now quoted after the status line as
    Response body: ..., capped at 2000 characters with the usual
    ...[truncated, original N chars] marker. The exception type and the response
    it carries are unchanged, so the 429/5xx retry and the network-outage budget
    still classify a failure exactly as before. This also revives the API-shape
    fallbacks in Invoke-Shp, which match the error text for store,
    unsupported_api_for_model, invalid_request_body and reasoning /
    summary: they were unreachable whenever the reply was buffered, so for
    example Invoke-Shp -Model gpt-5.5 -DisableStreaming failed on a bare 400
    instead of falling back to /responses the way the streaming path already did.

  • Invoke-Shp now changes the API shape at most once per turn. Both shape
    fallbacks rewind the iteration counter before retrying, so -MaxToolIterations
    never bounded them, and a service that refused /chat/completions and
    /responses with the same code could bounce a turn between the two
    indefinitely - one billable request per hop. The first switch is still made;
    the second refusal now surfaces as an error.

  • Corrected the GPT-5.6 rates against the published GitHub Copilot billing
    table. gpt-5.6-luna was charged 5x its real rate (now 0.20/0.02/0.25/1.20
    per 1M tokens) and gpt-5.6-terra 25% over (now 2.00/0.20/2.50/12.00); all
    three GPT-5.6 models bill a cache write that the table previously recorded as
    $null, so cache-write tokens were costed as free.

  • Added the missing grok-4.5 rate, so xAI calls are no longer unpriced.

  • The fetch_url tool no longer reaches private networks. Every URL, including
    each redirect target, is checked before the request: only http and https
    are allowed, and host names must resolve to publicly routable addresses.
    Loopback, link-local (including the 169.254.169.254 cloud metadata address),
    RFC 1918, carrier-grade NAT, 0.0.0.0/8, multicast and their IPv6 equivalents
    are refused, as are IPv4-mapped forms and names that fail to resolve.
    Redirects are followed manually, up to five hops, so a public URL can no
    longer bounce the model into the host's own network.

  • Corrected the gpt-5.6 rates, which shipped as placeholders. gpt-5.6-luna
    was charged five times its real rate (now 1.00 / 0.10 / 6.00 USD per million
    input / cached-input / output tokens) and gpt-5.6-terra twice
    (now 2.50 / 0.25 / 15.00). gpt-5.6-sol was already correct.

  • Cost is now calculated per round-trip instead of on the turn totals. A model's
    long-context rate is selected by a single request's input size, so a turn made
    of several smaller round-trips is no longer at risk of being priced as one
    oversized request, and a genuinely oversized request is no longer under-priced
    at the default rate. Added the published thresholds and long-context rates for
    gpt-5.4, gpt-5.5, gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra and
    gemini-3.1-pro.

  • Added the missing price-table entries for models the service advertises:
    gemini-3-flash-preview, gemini-3.1-pro-preview, gemini-3.6-flash and
    mai-code-1-flash-picker, which all reported empty cost and credit fields.
    Also added published rates for claude-fable-5, claude-opus-4.8-fast and
    kimi-k2.7-code.

  • Request bodies are now serialised with a stable key order. PowerShell
    hashtables have no defined enumeration order and .NET randomises string
    hashing per process, so the same payload could serialise differently between
    runs and defeat backend prompt caching.

  • Invoke-Shp and Get-ShpCostEstimate now report CostUSD and Credits for
    claude-opus-5 and claude-sonnet-5. Neither model had an entry in
    data/PriceTable.psd1, and the price lookup matches the model id exactly, so
    every call using them returned empty cost, credit and cost-breakdown fields.
    The rates are the published ones: Opus 5 at 5.00 / 0.50 / 6.25 / 25.00 USD per
    million input / cached-input / cache-write / output tokens, and Sonnet 5 at
    its introductory 2.00 / 0.20 / 2.50 / 10.00 (the standard
    3.00 / 0.30 / 3.75 / 15.00 takes effect on 2026-09-01).