Repository navigation
v0.4.0-preview0008
Pre-release[v0.4.0-preview0008]
Added
Register-ShpMcpServer,Get-ShpMcpServerandUnregister-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 withInvoke-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 theinitializehandshake entirely - a modern
request is stateless and carries its protocol version and client capabilities
in_meta, withserver/discoveras a mandatory RPC - while nearly every
server in the field still expects the handshake. The client therefore probes
withserver/discoverand falls back toinitializeon 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-ShpToolnow lists MCP tools next to user tools, with a newOrigin
column (UserorMcp) and the contributingServer.Invoke-Shpreports
McpEnabled,McpServersAvailable,McpToolsAvailableandMcpToolsCalled,
and an MCP call emits the sameToolCallprogress 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,
.vscodeor a
user profile.Register-ShpMcpServer -Pathreads 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/listenstream, so it receives no
notifications/tools/list_changedand 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.
- Nothing is discovered. No scan of the working directory,
Set-ShpToolPolicycannot 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-inread_filewas denied with a reason and the
MCP tool call ran. A policy that scopesread_fileto 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-DisableFileAccessread nothing and
leaked nothing. - A configuration entry asking for
sandboxEnabledwarns 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, plusSandboxRequestedon 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-ShpMcpServerreports 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-ShpBatchdoes 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
Faultedrather 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 withRegister-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\Administratorsas well as by you.
The file is self-describing -SHPv1:DPAPI:...orSHPv1:NONE:...- and
Initialize-Shpreports 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 isNONE
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-SecureStringis 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-Shpwithout 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
namingInitialize-Shp -Force, rather than sending a ciphertext to the
service as a bearer token. -
Set-ShpToolPolicy,Get-ShpToolPolicyandClear-ShpToolPolicyscope 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 writtenRead(./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 asToolCallsDenied,
and the policy travels into everyInvoke-ShpBatchworker, because a worker
inherits no module state and would otherwise be the one unguarded path.
This closes a real gap rather than a symmetry:-Confirmis interactive only
so an unattended run never prompts, and-DisableFileAccess/
-DisableTerminalare 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 forout
cannot matchoutsider.run_commandis 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. AShellrule constrains which program runs, not what it does;
the spec states that limit and the others plainly. -
Invoke-Shp -RetryDelaySec, and-TimeoutSec,-MaxRetryCount,
-RetryDelaySecand-NetworkOutageToleranceSeconGet-ShpModeland
Request-ShpEmbedding. All four now resolve identically everywhere - explicit
parameter, thenSet-ShpContext, then the built-in default - and apply to
every request the module makes, including the OAuth-to-session token
exchange,/modelsand embeddings. Those three previously called the retry
wrapper with the module's own defaults, soSet-ShpContext -TimeoutSec 10 -MaxRetryCount 0silently did not apply to them; andInvoke-Shpresolved
its options after the token exchange had already run, so an explicit
-TimeoutSecnever reached the one request that gates every other one. There
is deliberately no exemption for the auth handshake: the exchange is cached,
so-MaxRetryCount 0costs 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-ShpChatdrops 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-ShpChatand a stateless-Historycall, and both throw the whole
conversation away.Invoke-Shpwrites 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,-WhatIfreports 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-Shpnow 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, becauseConvertTo-ShpTokenCountwas 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,
-MaxContextWindowTokensresolves 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 asContextBudgetand
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.5advertises 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-ShpModelrecords 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
/modelsper turn would add a round-trip to calls that are otherwise local.
Until something fills it -Get-ShpModel, orGet-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. RunGet-ShpModelonce
per session to fix that.Invoke-ShpBatchcopies the cache to every worker,
andInitialize-Shpdiscards 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 asPriced/PriceTableKeymaking an unpriced call observable. A
cache that has simply never been populated - the default state of every
session - stays quiet. -
Get-ShpUsagenow records calls that failed, not only the ones that
succeeded. A failed call carriesSuccess$falseand 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-ShpBatchinherits this, so a failed batch item now
shows up inGet-ShpUsagetoo.
See specs/016-failed-call-usage-accounting.md. -
Get-ShpUsage -SummarygainedSucceeded,Failed,TotalDurationMs,
MeanDurationMs,FirstCall,LastCallandElapsedMs, and theByModel
breakdown gainedSucceeded,FailedandDurationMs.ElapsedMsis
wall-clock between the first and last call and is deliberately not the sum
ofDurationMs: underInvoke-ShpBatchthe 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 -Sinceand-Beforefilter 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-ShpUsagereturns the records, soGroup-Objectalready groups by any
field, andByModelis pre-aggregated only because that split is the common
case. -
Invoke-ShpBatchruns many independent prompts concurrently and returns one
ShellPilot.BatchResultper input, carrying the answer, that item's usage and
cost, and - when the call failed - the error.-ThrottleLimitbounds how many
calls are in flight (default 4, deliberately conservative), and prompts can be
piped in as plain strings or as objects withPromptand an optionalId.
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 ofInvoke-Shpcalls grow until the
model refuses it withmodel_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
$ErrorActionPreferenceand would destroy every result underStop; check
SuccessandErroron 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 hasIndex,Idand the originalInputObject.
-MaxBatchBudgetUSDcaps the whole run as a gate on dispatch - calls already
in flight are never cancelled. Streaming,ask_userand 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 intoGet-ShpUsage.
See specs/015-batch-execution.md. -
Invoke-Shp -MaxContextWindowTokensandSet-ShpContext -MaxContextWindowTokensset 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.5is 136000 - so the
guard could never fire for a smaller model; set this from the model's own
MaxContextWindowTokens(seeGet-ShpModel) to make it fire when it should.
0disables 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-ShpChator-History. -
Invoke-Shpnow explains a rejection it cannot recover from. A service reply
ofmodel_max_prompt_tokens_exceededis emitted as a warning naming the real
cause - every-Promptcall 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,-TopPand-Seedcontrol the model's sampling,
so a call that has to be reproducible (grading or judging in an evaluation
harness) can pin itself with-Temperature 0and 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.-Temperatureis validated against 0..2 and
-TopPagainst 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 asTemperature,TopPand
Seed. See specs/014-sampling-parameters.md. -
Invoke-ShpandGet-ShpCostEstimateresults carryPricedand
PriceTableKey, so a call the price table cannot cost is no longer
indistinguishable from a free one.PriceTableKeystays 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.CostUSDandCreditsare unchanged and stillnull- never0-
when no rate is found.Pricedis also recorded on eachGet-ShpUsageentry. -
Resolve-ShpErrorexplains 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 usualInvoke-Shpresult. Every
tool is disabled unless-EnableToolsis passed, so diagnosing an error
cannot touch the machine. -
Invoke-ShpsupportsShouldProcess.-WhatIfdry-runs a whole agent turn -
the model still plans and calls tools, butwrite_file,create_directory,
run_commandand user-registered tools are skipped and told they were not
approved - and-Confirmprompts before each of those calls. Default
behaviour is unchanged. -
Invoke-Shp -MaxBudgetUSDstops the tool-calling loop once the turn's
estimated spend passes the cap, and the result carries a newBudgetExceeded
flag. -
Invoke-Shp -AppendSystemPromptadds inline system instructions in either
parameter set, so a file-driven system prompt can still be topped up for a
single call. -
Invoke-Shp -AllowPrivateNetworkopts thefetch_urltool back in to
loopback, link-local and private addresses. -
Start-ShpChatgained the/models,/history,/retryand/usage
commands./retrydrops the last exchange and resends the previous prompt. -
The price table supports a long-context tier. An entry may carry a
LongContextblock with aThresholdin input tokens plus its own rates, and
the cost breakdown now reportsTierandTiersUsed.
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 sharedHttpClientis
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_exceededwarning now explains that a failed call
is not written back, so the conversation stays pinned and every retry fails
identically, and it namesCompress-ShpChatalongsideClear-ShpChatand
-History. -
Get-ShpUsage -Summary'sCallsandCostUSDwill report different
numbers for a session in which something failed.Callsnow counts calls
attempted; it previously counted records, and only successes were recorded,
so it meant "calls that succeeded". ReadSucceededfor the old number.
CostUSDnow 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 readingCallsas 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 - severalInvoke-ShpBatchworkers refused by
the same 429 would sleep identical durations and re-fire together, recreating
the burst that caused the refusal. ARetryDelaySecof0still yields
exactly0, 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) withIDE 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-Shpnow 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 namingInitialize-Shp, and an alternative
backend authenticating with your own API key never has itsAuthorization
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_commandnow 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 -ArgumentListas 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 andCommandsRunentry
recorded a command that never executed. The child is now started through
System.Diagnostics.ProcessStartInfowith anArgumentList, 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,MaxCharscapping, the timeout envelope, the process-tree
kill, the default working directory and theworkingDirectoryoverride are all
unchanged, as is the environment the child inherits. -
Streamed
Invoke-Shprequests now use the same retry wrapper as buffered
requests. HTTP 429/5xx responses are bounded byMaxRetryCount, and a true
no-response transport failure usesNetworkOutageToleranceSec. The classifier
reads the streaming sender's structuredStatusCodebefore its bare
HttpRequestExceptiontype, 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.-Historyis
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 theInvoke-Shpdefault, so this was the common path on
which a caller still had to match substrings in an exception message. A
non-success status fromInvoke-ShpStreamRequestnow raises an ErrorRecord
with the body onErrorDetails.Messageand aShellPilot.HttpErrorDetailon
TargetObject, which is also the only programmatic home the status has on
that path -HttpRequestExceptioncarries 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-ShpHttpRequestraises a built error
record rather than a bare exception, so$_.ErrorDetails.Messagecarries the
response body the wayInvoke-RestMethoddoes, and$_.TargetObjectcarries a
ShellPilot.HttpErrorDetailwithStatusCode, the service's ownErrorCode
andParam, itsMessage, the whole rawBodyand theRequestUri- 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.Messagefirst, 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 stableShpHttpRequestFailed,Invoke-ShpHttpRequesterror id instead of
by an error id that repeated the whole message. BecauseErrorDetails.Message
replaces the record's display text, it is capped like the exception message;
TargetObject.Bodykeeps the body whole. This covers every buffered call -
Invoke-Shp -DisableStreaming, every/responsesturn,/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 fromInvoke-ShpStreamRequestput 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 inInvoke-Shp, which match the error text forstore,
unsupported_api_for_model,invalid_request_bodyandreasoning/
summary: they were unreachable whenever the reply was buffered, so for
exampleInvoke-Shp -Model gpt-5.5 -DisableStreamingfailed on a bare 400
instead of falling back to/responsesthe way the streaming path already did. -
Invoke-Shpnow 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/completionsand
/responseswith 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-lunawas charged 5x its real rate (now 0.20/0.02/0.25/1.20
per 1M tokens) andgpt-5.6-terra25% 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.5rate, so xAI calls are no longer unpriced. -
The
fetch_urltool no longer reaches private networks. Every URL, including
each redirect target, is checked before the request: onlyhttpandhttps
are allowed, and host names must resolve to publicly routable addresses.
Loopback, link-local (including the169.254.169.254cloud 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.6rates, 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) andgpt-5.6-terratwice
(now 2.50 / 0.25 / 15.00).gpt-5.6-solwas 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-terraand
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-flashand
mai-code-1-flash-picker, which all reported empty cost and credit fields.
Also added published rates forclaude-fable-5,claude-opus-4.8-fastand
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-ShpandGet-ShpCostEstimatenow reportCostUSDandCreditsfor
claude-opus-5andclaude-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).