feat: implemented anthropic keep-alive and migration support for updates

- Added Anthropic prompt-cache refresh scheduling and state management to keep prompts warm across idle sessions.
- Updated pricing models and database stats tracking to calculate and store cost-weighted cache savings.
- Integrated cache savings metrics and efficiency displays into the stats CLI, dashboard routes, and UI components.
- Added support for package renaming, manifest pointer tracking, and installation migration during CLI updates.
This commit is contained in:
can1357
2026-08-13 03:53:47 +02:00
parent b60bef961c
commit 1132c3e31c
35 changed files with 1488 additions and 518 deletions
+3 -1
View File
@@ -20,7 +20,6 @@ pkgs.mkShell (
bun
bun2nix
rustToolchain
bazelisk
cargo-nextest
rustPlatform.bindgenHook
nixfmt
@@ -61,6 +60,9 @@ pkgs.mkShell (
++ lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxLibraries;
CMAKE_POLICY_VERSION_MINIMUM = "3.5";
# Bazel's downloaded host tools assume an FHS loader; Cargo is the
# repository's supported local-iteration path inside the Nix shell.
OMP_NATIVE_BUILD_BACKEND = "cargo";
PCRE2_SYS_STATIC = "1";
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
}
+11 -15
View File
@@ -67,17 +67,6 @@ let
runHook postInstall
'';
};
runtimeLibraries = [
libopus
openssl
pcre2
]
++ lib.optionals stdenv.hostPlatform.isLinux [
libpulseaudio
pipewire
stdenv.cc.cc.lib
zlib
];
in
stdenv.mkDerivation (
{
@@ -170,11 +159,18 @@ stdenv.mkDerivation (
install -Dm755 packages/coding-agent/dist/omp "$out/libexec/omp/omp"
${lib.optionalString stdenv.hostPlatform.isLinux ''
# The addon is gzip-compressed inside the compiled binary, so the Nix
# store RUNPATH autoPatchelf wrote into it is invisible to the output
# reference scanner. Record it in plain text to pin those libraries
# (pipewire, libopus, libgcc) into the runtime closure.
mkdir -p "$out/nix-support"
patchelf --print-rpath "packages/natives/native/${platform.addon}" \
> "$out/nix-support/embedded-addon-runpath"
''}
makeWrapper "$out/libexec/omp/omp" "$out/bin/omp" \
--set PI_SKIP_VERSION_CHECK 1 \
${
if stdenv.hostPlatform.isLinux then "--prefix LD_LIBRARY_PATH" else "--prefix DYLD_LIBRARY_PATH"
} : "${lib.makeLibraryPath runtimeLibraries}"
--set PI_SKIP_VERSION_CHECK 1
runHook postInstall
'';
+1
View File
@@ -5,6 +5,7 @@
### Changed
- OpenCode Go usage now comes from the official `GET /zen/go/v1/usage` endpoint (rolling 5h / weekly / monthly percent windows with server-computed resets) instead of synthesizing dollar estimates from OMP-observed request costs, so `/usage` reflects spend made outside OMP and the hardcoded $12/$30/$60 caps are gone. The usage probe now validates credentials (401 invalid key, 403 lapsed Go subscription), and a new ranking strategy routes multi-key pools by rolling/weekly headroom while keeping the monthly window display-only (an exhausted monthly can still serve requests via the console "Use balance" fallback) ([#8337](https://github.com/can1357/oh-my-pi/pull/8337) by [@will-bogusz](https://github.com/will-bogusz)).
- Changed direct Anthropic prompt caching to two rolling 5-minute breakpoints on the latest real messages. Opt-in session owners can now keep the prefix warm through three bounded idle refreshes, using non-streaming `max_tokens: 0` when thinking is inactive and aborting immediately after generation begins when thinking is active.
### Fixed
+6 -10
View File
@@ -27,7 +27,7 @@ import { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeout
export { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeoutError };
import type { FetchImpl } from "../types";
import type { MessageCreateParamsStreaming } from "./anthropic-wire";
import type { MessageCreateParams } from "./anthropic-wire";
/** Default pre-response timeout, matching the SDK's 10-minute default. */
const DEFAULT_TIMEOUT_MS = 600_000;
@@ -173,7 +173,7 @@ export class AnthropicMessages {
this.#path = path;
}
create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest {
create(params: MessageCreateParams, options?: AnthropicRequestOptions): AnthropicApiRequest {
return this.#client.request(this.#path, params, options);
}
}
@@ -184,8 +184,8 @@ export class AnthropicMessages {
* alternative Messages-API client via `AnthropicOptions.client`.
*/
export interface AnthropicMessagesClientLike {
messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown };
beta?: { messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown } };
messages: { create(params: MessageCreateParams, options?: AnthropicRequestOptions): unknown };
beta?: { messages: { create(params: MessageCreateParams, options?: AnthropicRequestOptions): unknown } };
}
export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
@@ -199,7 +199,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
this.beta = { messages: new AnthropicMessages(this, "/v1/messages?beta=true") };
}
request(path: string, params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest {
request(path: string, params: MessageCreateParams, options?: AnthropicRequestOptions): AnthropicApiRequest {
return new AnthropicApiRequest(() => this.#send(path, params, options));
}
@@ -218,11 +218,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
return headers;
}
async #send(
path: string,
params: MessageCreateParamsStreaming,
options?: AnthropicRequestOptions,
): Promise<Response> {
async #send(path: string, params: MessageCreateParams, options?: AnthropicRequestOptions): Promise<Response> {
const opts = this.#options;
const fetchFn: FetchImpl = opts.fetch ?? fetch;
const callerSignal = options?.signal;
+107 -259
View File
@@ -80,6 +80,7 @@ import {
type ContentBlockParam,
type FallbackParam,
isAnthropicWebSearchHistoryBlock,
type MessageCreateParams,
type MessageCreateParamsStreaming,
type MessageParam,
type RawMessageStreamEvent,
@@ -482,17 +483,11 @@ function dropAnthropicStrictTools(params: MessageCreateParamsStreaming): void {
function getCacheControl(
model: Model<"anthropic-messages">,
cacheRetention: CacheRetention | undefined,
isOAuthToken: boolean,
): { retention: CacheRetention; cacheControl?: AnthropicCacheControl } {
// OAuth mirrors Claude Code and always defaults to 1h retention. API-key
// requests also default to 1h where the endpoint supports it (canonical
// Anthropic API, `compat.supportsLongCacheRetention`): agent sessions
// routinely idle past 5 minutes waiting on background jobs, and a 5m
// breakpoint cold-misses the entire prefix on resume. PI_CACHE_RETENTION
// still overrides the API-key default in either direction.
const retention = isOAuthToken
? (cacheRetention ?? "long")
: resolveCacheRetention(cacheRetention, model.compat.supportsLongCacheRetention ? "long" : "short");
// Five-minute writes are the cheapest cache population strategy. Longer
// retention remains an explicit PI_CACHE_RETENTION/request override; idle
// sessions keep the short entry warm with bounded read-only refreshes.
const retention = resolveCacheRetention(cacheRetention, "short");
if (retention === "none") {
return { retention };
}
@@ -1653,6 +1648,31 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi
}
}
function parseAnthropicWireUsage(value: unknown): AnthropicWireUsage | undefined {
if (!isRecord(value)) return undefined;
const cacheCreation = isRecord(value.cache_creation)
? {
...(typeof value.cache_creation.ephemeral_5m_input_tokens === "number"
? { ephemeral_5m_input_tokens: value.cache_creation.ephemeral_5m_input_tokens }
: {}),
...(typeof value.cache_creation.ephemeral_1h_input_tokens === "number"
? { ephemeral_1h_input_tokens: value.cache_creation.ephemeral_1h_input_tokens }
: {}),
}
: undefined;
return {
...(typeof value.input_tokens === "number" ? { input_tokens: value.input_tokens } : {}),
...(typeof value.output_tokens === "number" ? { output_tokens: value.output_tokens } : {}),
...(typeof value.cache_read_input_tokens === "number"
? { cache_read_input_tokens: value.cache_read_input_tokens }
: {}),
...(typeof value.cache_creation_input_tokens === "number"
? { cache_creation_input_tokens: value.cache_creation_input_tokens }
: {}),
...(cacheCreation === undefined ? {} : { cache_creation: cacheCreation }),
};
}
function parseAnthropicFallbackWireBlock(value: unknown): AnthropicFallbackContent | undefined {
if (!isRecord(value) || value.type !== "fallback") return undefined;
const from = isRecord(value.from) && typeof value.from.model === "string" ? value.from.model : undefined;
@@ -1857,6 +1877,7 @@ const streamAnthropicOnce = (
});
}
const zeroOutputCacheRefresh = options?.anthropicCacheRefreshRequest === true;
let client: AnthropicMessagesClientLike;
let isOAuthToken: boolean;
@@ -1927,7 +1948,7 @@ const streamAnthropicOnce = (
// requests must not deviate from CC's header fingerprint.
if (
!(options?.isOAuth ?? isAnthropicOAuthToken(apiKey)) &&
getCacheControl(model, options?.cacheRetention, false).cacheControl?.ttl === "1h" &&
getCacheControl(model, options?.cacheRetention).cacheControl?.ttl === "1h" &&
!extraBetas.includes(extendedCacheTtlBeta)
) {
extraBetas.push(extendedCacheTtlBeta);
@@ -1958,7 +1979,7 @@ const streamAnthropicOnce = (
model,
apiKey,
extraBetas,
stream: true,
stream: !zeroOutputCacheRefresh,
interleavedThinking: options?.interleavedThinking ?? true,
headers: options?.headers,
dynamicHeaders: copilotDynamicHeaders?.headers,
@@ -2005,6 +2026,60 @@ const streamAnthropicOnce = (
return nextParams;
};
let params = await prepareParams();
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
if (zeroOutputCacheRefresh) {
const refreshParams: MessageCreateParams = { ...params, max_tokens: 0, stream: false };
rawRequestDump = {
provider: model.provider,
api: output.api,
model: model.id,
method: "POST",
url: `${baseUrl}/v1/messages${isOAuthToken ? "?beta=true" : ""}`,
body: refreshParams,
};
const { requestSignal } = activeAbortTracker;
const requestOptions = {
...createSdkStreamRequestOptions(requestSignal, requestTimeoutMs),
maxRetries: 0,
};
const request: unknown =
isOAuthToken && client.beta
? client.beta.messages.create(refreshParams, requestOptions)
: client.messages.create(refreshParams, requestOptions);
if (!hasAnthropicRawResponseRequest(request)) {
throw new AIError.AnthropicStreamEnvelopeError(
"Anthropic cache refresh request did not expose a raw response",
);
}
const response = await request.asResponse();
await notifyProviderResponse(options, response, model, response.headers.get("request-id"));
const body: unknown = await response.json();
if (!isRecord(body)) {
throw new AIError.AnthropicStreamEnvelopeError("Anthropic cache refresh returned a malformed response");
}
const wireUsage = parseAnthropicWireUsage(body.usage);
if (!wireUsage) {
throw new AIError.AnthropicStreamEnvelopeError("Anthropic cache refresh response omitted usage");
}
if (typeof body.id === "string") output.responseId = body.id;
output.usage.input = wireUsage.input_tokens ?? 0;
output.usage.output = wireUsage.output_tokens ?? 0;
output.usage.cacheRead = wireUsage.cache_read_input_tokens ?? 0;
output.usage.cacheWrite = wireUsage.cache_creation_input_tokens ?? 0;
applyAnthropicUsageExtras(output.usage, wireUsage);
output.usage.totalTokens =
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
calculateCost(model, output.usage);
output.duration = performance.now() - startTime;
stream.push({ type: "start", partial: output });
stream.push({ type: "done", reason: "stop", message: output });
stream.end();
return;
}
// Opt-in flag: the response parser only honors `fallback` content
// blocks and `usage.iterations` when the current request opted into
@@ -2019,10 +2094,6 @@ const streamAnthropicOnce = (
| (AnthropicServerToolContent & { [kStreamingPartialJson]?: string })
| (ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number })
) & { [kStreamingBlockIndex]: number };
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const blocks = output.content as Block[];
const finalizeStreamBlock = (block: Block, contentIndex: number): void => {
if (block.type === "text") {
@@ -2807,65 +2878,19 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, con
export type AnthropicSystemBlock = {
type: "text";
text: string;
cache_control?: AnthropicCacheControl;
};
type SystemBlockOptions = {
includeClaudeCodeInstruction?: boolean;
extraInstructions?: string[];
/** Text of the first user message — used as fingerprint seed for the billing header. */
firstUserMessageText?: string;
cacheControl?: AnthropicCacheControl;
};
/**
* Place system-block cache breakpoints that survive volatile project context.
*
* omp normally appends its project footer (cwd, date, workspace tree) after the
* stable system prefix. When cwd is outside a single direct child repository,
* an active-repo context block follows that footer. Caching up to the last three
* eligible blocks therefore covers both layouts:
*
* - stable prefix, project footer
* - stable prefix, project footer, active-repo context
*
* A footer change can then fall back to the stable-prefix entry instead of
* re-writing the entire system cache (issue #7324).
*
* @returns breakpoints placed, capped by `maxBreakpoints`.
*/
function cacheSystemPrefixBreakpoints(
blocks: AnthropicSystemBlock[],
cacheControl: AnthropicCacheControl | undefined,
maxBreakpoints: number,
firstCacheableIndex: number,
): number {
if (!cacheControl || maxBreakpoints <= 0) return 0;
let placed = 0;
for (let index = blocks.length - 1; index >= firstCacheableIndex && placed < maxBreakpoints; index--) {
if (blocks[index].cache_control != null) continue;
blocks[index] = { ...blocks[index], cache_control: cloneAnthropicCacheControl(cacheControl) };
placed++;
}
return placed;
}
/**
* First system-block index that may carry a cache breakpoint. Skips the OAuth
* cloak blocks that must stay uncached: the CC billing header (block 0, a
* per-request fingerprint) and the Claude Code identity instruction (block 1).
*/
function firstCacheableSystemIndex(blocks: readonly AnthropicSystemBlock[]): number {
let index = 0;
if (blocks[index]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX)) index++;
if (blocks[index]?.text === claudeCodeSystemInstruction) index++;
return index;
}
export function buildAnthropicSystemBlocks(
systemPrompt: readonly string[] | undefined,
options: SystemBlockOptions = {},
): AnthropicSystemBlock[] | undefined {
const { includeClaudeCodeInstruction = false, extraInstructions = [], firstUserMessageText, cacheControl } = options;
const { includeClaudeCodeInstruction = false, extraInstructions = [], firstUserMessageText } = options;
const sanitizedPrompts = normalizeSystemPrompts(systemPrompt);
const trimmedInstructions = extraInstructions.map(instruction => instruction.trim()).filter(Boolean);
const hasBillingHeader = sanitizedPrompts.some(prompt => prompt.startsWith(CLAUDE_BILLING_HEADER_PREFIX));
@@ -2882,7 +2907,6 @@ export function buildAnthropicSystemBlocks(
for (const prompt of sanitizedPrompts) {
blocks.push({ type: "text", text: prompt });
}
cacheSystemPrefixBreakpoints(blocks, cacheControl, 3, firstCacheableSystemIndex(blocks));
return blocks;
}
@@ -2894,10 +2918,6 @@ export function buildAnthropicSystemBlocks(
for (const prompt of sanitizedPrompts) {
blocks.push({ type: "text", text: prompt });
}
const lastIndex = blocks.length - 1;
if (cacheControl && lastIndex >= 0 && blocks[lastIndex].cache_control == null) {
blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) };
}
return blocks.length > 0 ? blocks : undefined;
}
@@ -3149,29 +3169,17 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, maxAll
thinking.budget_tokens = clampedBudget;
}
type CacheControlBlock = {
cache_control?: AnthropicCacheControl | null;
};
function applyCacheControlToLastTextBlock(
blocks: Array<ContentBlockParam & CacheControlBlock>,
cacheControl: AnthropicCacheControl,
): boolean {
if (blocks.length === 0) return false;
for (let i = blocks.length - 1; i >= 0; i--) {
if (blocks[i].type === "text") {
if (blocks[i].cache_control != null) return false;
blocks[i] = { ...blocks[i], cache_control: cloneAnthropicCacheControl(cacheControl) };
return true;
function applyCacheControlToLastBlock(blocks: ContentBlockParam[], cacheControl: AnthropicCacheControl): boolean {
for (let index = blocks.length - 1; index >= 0; index--) {
const block = blocks[index];
// Anthropic rejects cache_control on generated reasoning and fallback
// boundary blocks. Preserve the requested trailing boundary on every
// ordinary content block, including tool use and tool results.
if (block.type === "thinking" || block.type === "redacted_thinking" || block.type === "fallback") {
continue;
}
}
// No text block — fall back to the last block that accepts cache_control;
// thinking/redacted_thinking blocks reject the field with a 400.
for (let i = blocks.length - 1; i >= 0; i--) {
const type = blocks[i].type;
if (type === "thinking" || type === "redacted_thinking") continue;
if (blocks[i].cache_control != null) return false;
blocks[i] = { ...blocks[i], cache_control: cloneAnthropicCacheControl(cacheControl) };
if ("cache_control" in block && block.cache_control != null) return false;
blocks[index] = { ...block, cache_control: cloneAnthropicCacheControl(cacheControl) };
return true;
}
return false;
@@ -3180,28 +3188,10 @@ function applyCacheControlToLastTextBlock(
function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: AnthropicCacheControl): void {
if (!cacheControl) return;
const MAX_CACHE_BREAKPOINTS = 4;
let cacheBreakpointsUsed = countCacheControlBreakpoints(params);
if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
let isCCLayout = false;
if (params.system && Array.isArray(params.system) && params.system.length > 0) {
isCCLayout = params.system[0]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true;
const maxSystemBreakpoints = Math.min(3, MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed);
cacheBreakpointsUsed += cacheSystemPrefixBreakpoints(
params.system as AnthropicSystemBlock[],
cacheControl,
maxSystemBreakpoints,
isCCLayout ? firstCacheableSystemIndex(params.system as AnthropicSystemBlock[]) : 0,
);
}
if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
// `convertAnthropicMessages` appends this neutral pad after a trailing
// assistant because Anthropic rejects assistant-prefill endings. It is absent
// from the next normal turn, so caching it wastes a scarce breakpoint; anchor
// the cache window on the preceding real assistant instead.
// from the next normal turn, so anchor the rolling window on the preceding
// real assistant instead.
const trailingIndex = params.messages.length - 1;
const trailingMessage = params.messages[trailingIndex];
const hasTrailingAssistantPad =
@@ -3209,158 +3199,18 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
trailingMessage.content === "Continue." &&
params.messages[trailingIndex - 1]?.role === "assistant";
const messageEnd = hasTrailingAssistantPad ? trailingIndex - 1 : trailingIndex;
const messageWindowSize = isCCLayout ? 1 : 2;
const start = Math.max(0, messageEnd - messageWindowSize + 1);
for (let i = messageEnd; i >= start; i--) {
if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) break;
const message = params.messages[i];
const start = Math.max(0, messageEnd - 1);
for (let index = messageEnd; index >= start; index--) {
const message = params.messages[index];
if (!message) continue;
if (typeof message.content === "string") {
message.content = [
{ type: "text", text: message.content, cache_control: cloneAnthropicCacheControl(cacheControl) },
];
cacheBreakpointsUsed++;
} else if (Array.isArray(message.content) && message.content.length > 0) {
if (
applyCacheControlToLastTextBlock(
message.content as Array<ContentBlockParam & CacheControlBlock>,
cacheControl,
)
) {
cacheBreakpointsUsed++;
} else if (Array.isArray(message.content)) {
applyCacheControlToLastBlock(message.content, cacheControl);
}
}
}
}
function normalizeCacheControlBlockTtl(block: CacheControlBlock, seenFiveMinute: { value: boolean }): void {
const cacheControl = block.cache_control;
if (!cacheControl) return;
if (cacheControl.ttl !== "1h") {
seenFiveMinute.value = true;
return;
}
if (seenFiveMinute.value) {
const normalized = cloneAnthropicCacheControl(cacheControl);
delete normalized.ttl;
block.cache_control = normalized;
}
}
function normalizeCacheControlTtlOrdering(params: MessageCreateParamsStreaming): void {
const seenFiveMinute = { value: false };
if (params.tools) {
for (const tool of params.tools as Array<AnthropicWireTool & CacheControlBlock>) {
normalizeCacheControlBlockTtl(tool, seenFiveMinute);
}
}
if (params.system && Array.isArray(params.system)) {
for (const block of params.system as Array<AnthropicSystemBlock & CacheControlBlock>) {
normalizeCacheControlBlockTtl(block, seenFiveMinute);
}
}
for (const message of params.messages) {
if (!Array.isArray(message.content)) continue;
for (const block of message.content as Array<ContentBlockParam & CacheControlBlock>) {
normalizeCacheControlBlockTtl(block, seenFiveMinute);
}
}
}
function findLastCacheControlIndex<T extends CacheControlBlock>(blocks: T[]): number {
for (let index = blocks.length - 1; index >= 0; index--) {
if (blocks[index]?.cache_control != null) return index;
}
return -1;
}
function stripCacheControlExceptIndex<T extends CacheControlBlock>(
blocks: T[],
preserveIndex: number,
excessCounter: { value: number },
): void {
for (let index = 0; index < blocks.length && excessCounter.value > 0; index++) {
if (index === preserveIndex) continue;
if (!blocks[index]?.cache_control) continue;
delete blocks[index].cache_control;
excessCounter.value--;
}
}
function stripAllCacheControl<T extends CacheControlBlock>(blocks: T[], excessCounter: { value: number }): void {
for (const block of blocks) {
if (excessCounter.value <= 0) return;
if (!block.cache_control) continue;
delete block.cache_control;
excessCounter.value--;
}
}
function stripMessageCacheControl(
messages: MessageCreateParamsStreaming["messages"],
excessCounter: { value: number },
): void {
for (const message of messages) {
if (excessCounter.value <= 0) return;
if (!Array.isArray(message.content)) continue;
for (const block of message.content as Array<ContentBlockParam & CacheControlBlock>) {
if (excessCounter.value <= 0) return;
if (!block.cache_control) continue;
delete block.cache_control;
excessCounter.value--;
}
}
}
function countCacheControlBreakpoints(params: MessageCreateParamsStreaming): number {
let total = 0;
if (params.tools) {
for (const tool of params.tools as Array<AnthropicWireTool & CacheControlBlock>) {
if (tool.cache_control) total++;
}
}
if (params.system && Array.isArray(params.system)) {
for (const block of params.system as Array<AnthropicSystemBlock & CacheControlBlock>) {
if (block.cache_control) total++;
}
}
for (const message of params.messages) {
if (!Array.isArray(message.content)) continue;
for (const block of message.content as Array<ContentBlockParam & CacheControlBlock>) {
if (block.cache_control) total++;
}
}
return total;
}
function enforceCacheControlLimit(params: MessageCreateParamsStreaming, maxBreakpoints: number): void {
const total = countCacheControlBreakpoints(params);
if (total <= maxBreakpoints) return;
const excessCounter = { value: total - maxBreakpoints };
const systemBlocks =
params.system && Array.isArray(params.system)
? (params.system as Array<AnthropicSystemBlock & CacheControlBlock>)
: [];
const toolBlocks = (params.tools ?? []) as Array<AnthropicWireTool & CacheControlBlock>;
const lastSystemIndex = findLastCacheControlIndex(systemBlocks);
const lastToolIndex = findLastCacheControlIndex(toolBlocks);
if (systemBlocks.length > 0) {
stripCacheControlExceptIndex(systemBlocks, lastSystemIndex, excessCounter);
}
if (excessCounter.value <= 0) return;
if (toolBlocks.length > 0) {
stripCacheControlExceptIndex(toolBlocks, lastToolIndex, excessCounter);
}
if (excessCounter.value <= 0) return;
stripMessageCacheControl(params.messages, excessCounter);
if (excessCounter.value <= 0) return;
if (systemBlocks.length > 0) {
stripAllCacheControl(systemBlocks, excessCounter);
}
if (excessCounter.value <= 0) return;
if (toolBlocks.length > 0) {
stripAllCacheControl(toolBlocks, excessCounter);
}
}
function usesAdaptiveThinkingTagOnly(model: Model<"anthropic-messages">): boolean {
@@ -3445,7 +3295,7 @@ function buildParams(
forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking
? { ...model, compat: { ...model.compat, replayUnsignedThinking: false } }
: model;
const { cacheControl } = getCacheControl(model, options?.cacheRetention, isOAuthToken);
const { cacheControl } = getCacheControl(model, options?.cacheRetention);
// Pre-compute system blocks so they occupy the right slot in the serialized body.
const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku");
@@ -3582,7 +3432,7 @@ function buildParams(
...(systemBlocks && { system: systemBlocks }),
...(tools !== undefined && { tools }),
...(metadata && { metadata }),
max_tokens: Math.min(maxOutputTokens, options?.maxTokens || modelMaxTokens),
max_tokens: Math.min(maxOutputTokens, options?.maxTokens ?? modelMaxTokens),
...(thinking && { thinking }),
...(contextManagement && { context_management: contextManagement }),
...(outputConfig && { output_config: outputConfig }),
@@ -3647,8 +3497,6 @@ function buildParams(
disableThinkingIfToolChoiceForced(params, model);
ensureMaxTokensForThinking(params, maxOutputTokens);
applyPromptCaching(params, cacheControl);
enforceCacheControlLimit(params, 4);
normalizeCacheControlTtlOrdering(params);
return params;
}
+290 -1
View File
@@ -24,6 +24,7 @@ import { isInvalidatedOAuthTokenError } from "./error/auth-classify";
import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-limit";
import type { BedrockOptions } from "./providers/amazon-bedrock";
import type { AnthropicOptions } from "./providers/anthropic";
import type { MessageCreateParamsStreaming } from "./providers/anthropic-wire";
import { coworkFetch } from "./providers/cowork-fetch";
import type { CursorOptions } from "./providers/cursor";
import type { DevinOptions } from "./providers/devin";
@@ -69,6 +70,7 @@ import type {
FetchImpl,
Model,
OptionsForApi,
ProviderSessionState,
SimpleStreamOptions,
StreamOptions,
ThinkingBudgets,
@@ -1118,10 +1120,296 @@ function emitBufferedEvents(stream: AssistantMessageEventStream, events: Assista
}
}
const ANTHROPIC_CACHE_TTL_MS = 5 * 60_000;
const ANTHROPIC_CACHE_REFRESH_LEAD_MS = 15_000;
const ANTHROPIC_CACHE_REFRESH_LIMIT = 3;
const ANTHROPIC_CACHE_REFRESH_STATE_KEY = "anthropic-cache-refresh";
interface AnthropicCacheRefreshPlan {
refresh(controller: AbortController): Promise<number | undefined>;
}
class AnthropicCacheRefreshState implements ProviderSessionState {
#controller: AbortController | undefined;
#generation = 0;
#plan: AnthropicCacheRefreshPlan | undefined;
#refreshesRemaining = 0;
#timer: NodeJS.Timeout | undefined;
cancel(): void {
this.#generation++;
if (this.#timer !== undefined) {
clearTimeout(this.#timer);
this.#timer = undefined;
}
this.#controller?.abort();
this.#controller = undefined;
this.#plan = undefined;
this.#refreshesRemaining = 0;
}
arm(plan: AnthropicCacheRefreshPlan, cacheTouchedAtMs: number): void {
this.cancel();
this.#plan = plan;
this.#refreshesRemaining = ANTHROPIC_CACHE_REFRESH_LIMIT;
this.#schedule(cacheTouchedAtMs, this.#generation);
}
close(): void {
this.cancel();
}
#schedule(cacheTouchedAtMs: number, generation: number): void {
const refreshAtMs = cacheTouchedAtMs + ANTHROPIC_CACHE_TTL_MS - ANTHROPIC_CACHE_REFRESH_LEAD_MS;
this.#timer = setTimeout(
() => {
this.#timer = undefined;
void this.#refresh(generation);
},
Math.max(0, refreshAtMs - Date.now()),
);
this.#timer.unref?.();
}
async #refresh(generation: number): Promise<void> {
const plan = this.#plan;
if (generation !== this.#generation || !plan || this.#refreshesRemaining <= 0) return;
const controller = new AbortController();
this.#controller = controller;
let cacheTouchedAtMs: number | undefined;
try {
cacheTouchedAtMs = await plan.refresh(controller);
} catch (error) {
if (generation === this.#generation && !controller.signal.aborted) {
logger.debug("Anthropic prompt-cache refresh failed", { error: String(error) });
}
}
if (generation !== this.#generation) return;
this.#controller = undefined;
if (cacheTouchedAtMs === undefined) {
this.#plan = undefined;
this.#refreshesRemaining = 0;
return;
}
this.#refreshesRemaining--;
if (this.#refreshesRemaining <= 0) {
this.#plan = undefined;
return;
}
this.#schedule(cacheTouchedAtMs, generation);
}
}
function supportsAnthropicCacheRefresh<TApi extends Api>(model: Model<TApi>): boolean {
return (
model.api === "anthropic-messages" &&
model.provider === "anthropic" &&
model.transport !== "pi-native" &&
isLeakedThinkingHealExempt(model)
);
}
function isAnthropicRefreshPayload(payload: unknown): payload is MessageCreateParamsStreaming {
return (
typeof payload === "object" &&
payload !== null &&
"messages" in payload &&
Array.isArray(payload.messages) &&
"max_tokens" in payload &&
typeof payload.max_tokens === "number"
);
}
function isShortAnthropicCacheControl(cacheControl: unknown): boolean {
return (
typeof cacheControl === "object" &&
cacheControl !== null &&
"type" in cacheControl &&
cacheControl.type === "ephemeral" &&
(!("ttl" in cacheControl) || cacheControl.ttl !== "1h")
);
}
function hasShortAnthropicMessageBreakpoint(payload: MessageCreateParamsStreaming): boolean {
for (const message of payload.messages) {
if (!Array.isArray(message.content)) continue;
for (const block of message.content) {
if ("cache_control" in block && isShortAnthropicCacheControl(block.cache_control)) return true;
}
}
return false;
}
function isAnthropicGenerationEvent(event: AssistantMessageEvent): boolean {
switch (event.type) {
case "text_start":
case "thinking_start":
case "toolcall_start":
case "image_end":
return true;
case "text_delta":
case "thinking_delta":
case "toolcall_delta":
return event.delta.length > 0;
default:
return false;
}
}
function isAnthropicThinkingActive(model: Model<Api>, payload: MessageCreateParamsStreaming): boolean {
if (payload.thinking) return payload.thinking.type !== "disabled";
return model.thinking?.mode === "anthropic-adaptive" && payload.output_config?.effort != null;
}
function createAnthropicCacheRefreshPlan<TApi extends Api>(
model: Model<TApi>,
context: Context,
options: SimpleStreamOptions | undefined,
payload: MessageCreateParamsStreaming,
): AnthropicCacheRefreshPlan {
const thinkingEnabled = isAnthropicThinkingActive(model, payload);
return {
async refresh(controller) {
let cacheRead = 0;
let cacheWrite = 0;
let cacheTouchedAtMs: number | undefined;
let canceledAfterGenerationStarted = false;
const response = streamSimpleRequest(model, context, {
...options,
acceptEmptyResponse: true,
anthropicCacheRefreshRequest: !thinkingEnabled,
cacheRetention: "short",
maxTokens: thinkingEnabled ? options?.maxTokens : 0,
onPayload: () => ({
...payload,
max_tokens: thinkingEnabled ? payload.max_tokens : 0,
}),
onResponse: () => {
cacheTouchedAtMs = Date.now();
},
onSseEvent: undefined,
signal: controller.signal,
});
for await (const event of response) {
if ("partial" in event) {
cacheRead = event.partial.usage.cacheRead;
cacheWrite = event.partial.usage.cacheWrite;
}
if (event.type === "error") return undefined;
if (event.type === "done") {
cacheRead = event.message.usage.cacheRead;
cacheWrite = event.message.usage.cacheWrite;
return cacheTouchedAtMs !== undefined && cacheRead > 0 && cacheWrite === 0
? cacheTouchedAtMs
: undefined;
}
if (thinkingEnabled && isAnthropicGenerationEvent(event)) {
canceledAfterGenerationStarted = true;
controller.abort();
break;
}
}
if (canceledAfterGenerationStarted) {
try {
await response.result();
} catch (error) {
if (!controller.signal.aborted) throw error;
}
}
return cacheTouchedAtMs !== undefined && cacheRead > 0 && cacheWrite === 0 ? cacheTouchedAtMs : undefined;
},
};
}
function streamSimpleWithAnthropicCacheRefresh<TApi extends Api>(
model: Model<TApi>,
context: Context,
options: SimpleStreamOptions | undefined,
): AssistantMessageEventStream {
const providerSessionState = options?.providerSessionState;
if (!options?.anthropicCacheRefresh || !providerSessionState) {
return streamSimpleRequest(model, context, options);
}
const existingState = providerSessionState.get(ANTHROPIC_CACHE_REFRESH_STATE_KEY);
if (existingState instanceof AnthropicCacheRefreshState) {
existingState.cancel();
} else if (existingState) {
return streamSimpleRequest(model, context, options);
}
if (!supportsAnthropicCacheRefresh(model) || resolveCacheRetention(options.cacheRetention) !== "short") {
return streamSimpleRequest(model, context, options);
}
const refreshState = existingState ?? new AnthropicCacheRefreshState();
if (!existingState) providerSessionState.set(ANTHROPIC_CACHE_REFRESH_STATE_KEY, refreshState);
let cacheTouchedAtMs: number | undefined;
let capturedPayload: MessageCreateParamsStreaming | undefined;
const inner = streamSimpleRequest(model, context, {
...options,
onPayload: async (payload, payloadModel) => {
const replacement = await options?.onPayload?.(payload, payloadModel);
const finalPayload = replacement ?? payload;
if (isAnthropicRefreshPayload(finalPayload)) capturedPayload = finalPayload;
return replacement;
},
onResponse: async (response, responseModel) => {
cacheTouchedAtMs = Date.now();
await options?.onResponse?.(response, responseModel);
},
});
const outer = new AssistantMessageEventStream();
const armRefresh = (message: AssistantMessage): void => {
if (
message.stopReason === "error" ||
message.stopReason === "aborted" ||
message.usage.cacheRead + message.usage.cacheWrite <= 0 ||
cacheTouchedAtMs === undefined ||
capturedPayload === undefined ||
!hasShortAnthropicMessageBreakpoint(capturedPayload)
) {
return;
}
refreshState.arm(createAnthropicCacheRefreshPlan(model, context, options, capturedPayload), cacheTouchedAtMs);
};
void (async () => {
try {
for await (const event of inner) {
if (event.type === "done") armRefresh(event.message);
outer.push(event);
if (outer.done) return;
}
if (!outer.done) {
const result = await inner.result();
armRefresh(result);
outer.end(result);
}
} catch (error) {
outer.fail(error);
}
})();
return outer;
}
export function streamSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
return streamSimpleWithAnthropicCacheRefresh(model, context, options);
}
function streamSimpleRequest<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const inputOptions = (options || {}) as SimpleStreamOptions;
const baseOptions = { ...inputOptions, fetch: inputOptions.fetch ?? defaultFetchForModel(model) };
@@ -1147,7 +1435,7 @@ export function streamSimple<TApi extends Api>(
};
try {
const inner = streamSimple(model, context, { ...requestOptions, apiKey });
const inner = streamSimpleRequest(model, context, { ...requestOptions, apiKey });
for await (const event of inner) {
if (!emittedReplayUnsafeEvent && event.type === "start") {
bufferedEvents.push(event);
@@ -1613,6 +1901,7 @@ function mapOptionsForApi<TApi extends Api>(
fetch: options?.fetch,
fallbacks: options?.fallbacks,
acceptEmptyResponse: options?.acceptEmptyResponse,
anthropicCacheRefreshRequest: options?.anthropicCacheRefreshRequest,
...simpleProviderOptions,
};
+14 -4
View File
@@ -412,6 +412,16 @@ export interface StreamOptions {
signal?: AbortSignal;
apiKey?: string;
cacheRetention?: CacheRetention;
/**
* Keep Anthropic's 5-minute prompt cache warm across bounded idle gaps.
*
* This is an ownership flag, not a general provider default: exactly one
* primary agent loop sharing `providerSessionState` should enable it.
* Side-channel and advisor requests must leave it unset.
*/
anthropicCacheRefresh?: boolean;
/** @internal Marks a replay-only Anthropic request that must use non-streaming `max_tokens: 0`. */
anthropicCacheRefreshRequest?: boolean;
/**
* Additional headers to include in provider requests.
* These are merged on top of model-defined headers.
@@ -559,10 +569,10 @@ export interface StreamOptions {
*/
providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
/**
* Accept a Google `STOP` response with no visible text or tool call as a
* successful completion. Passive callers such as advisors use this because
* silence is a valid result; interactive agent turns retain empty-response
* retries by default. Ignored by non-Google providers.
* Accept a normal provider stop with no visible text or tool call as a
* successful completion. Passive callers and zero-output cache refreshes use
* this because silence is their expected result; interactive agent turns
* retain empty-response retries by default.
*/
acceptEmptyResponse?: boolean;
/**
@@ -63,6 +63,7 @@ function isMeaningfulCompletionEvent(event: AssistantMessageEvent): boolean {
interface EmptyCompletionRetryOptions {
signal?: AbortSignal;
providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
acceptEmptyResponse?: boolean;
}
/**
@@ -82,7 +83,7 @@ export function withEmptyCompletionRetry<M, O extends EmptyCompletionRetryOption
for (let emptyAttempt = 0; ; emptyAttempt++) {
const inner = attempt(model, context, options);
const buffered: AssistantMessageEvent[] = [];
let committed = false;
let committed = options?.acceptEmptyResponse === true;
let terminal: AssistantMessageEvent | undefined;
const flush = (): void => {
for (const event of buffered) outer.push(event);
@@ -117,6 +118,7 @@ export function withEmptyCompletionRetry<M, O extends EmptyCompletionRetryOption
// one-token invisible stop is still the same empty-completion failure.
const message = terminal?.type === "done" ? terminal.message : undefined;
const isRetryableEmpty =
options?.acceptEmptyResponse !== true &&
!committed &&
message !== undefined &&
message.stopReason === "stop" &&
+40 -110
View File
@@ -9,7 +9,6 @@ import {
applyClaudeToolPrefix,
buildAnthropicClientOptions,
buildAnthropicHeaders,
buildAnthropicSystemBlocks,
claudeCodeSystemInstruction,
claudeToolPrefix,
deriveClaudeDeviceId,
@@ -19,7 +18,7 @@ import {
streamAnthropic,
stripClaudeToolPrefix,
} from "@oh-my-pi/pi-ai/providers/anthropic";
import type { MessageCreateParamsStreaming } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
import type { MessageCreateParams } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
import { claudeCodeVersion } from "@oh-my-pi/pi-ai/providers/claude-code-fingerprint";
import { getEnvApiKey, streamSimple } from "@oh-my-pi/pi-ai/stream";
import type {
@@ -259,90 +258,7 @@ describe("Anthropic request fingerprint alignment", () => {
expect(options.defaultHeaders["anthropic-beta"]).not.toContain("context-1m-2025-08-07");
});
it("caches the stable prefix and the trailing block while leaving billing + CC identity uncached", () => {
const blocks = buildAnthropicSystemBlocks(["Stay concise."], {
includeClaudeCodeInstruction: true,
extraInstructions: ["Use citations when possible"],
cacheControl: { type: "ephemeral" },
});
expect(blocks).toHaveLength(4);
// OAuth cloak blocks stay uncached: the billing header is a per-request
// fingerprint and the CC identity block mimics Claude Code.
expect(blocks?.[0].text).toStartWith("x-anthropic-billing-header:");
expect(blocks?.[0].cache_control).toBeUndefined();
expect(blocks?.[1].text).toBe(claudeCodeSystemInstruction);
expect(blocks?.[1].cache_control).toBeUndefined();
// Stable-prefix breakpoint on the block before the trailing footer, plus a
// full-match breakpoint on the trailing block itself (#7324).
expect(blocks?.[2]).toEqual({
type: "text",
text: "Use citations when possible",
cache_control: { type: "ephemeral" },
});
expect(blocks?.[3]).toEqual({
type: "text",
text: "Stay concise.",
cache_control: { type: "ephemeral" },
});
});
it("keeps the stable-prefix breakpoint when the trailing project footer (cwd/date) changes (#7324)", () => {
const staticInstructions = "STATIC INSTRUCTIONS BLOCK";
const runA = buildAnthropicSystemBlocks([staticInstructions, "PROJECT\nToday is 2026-08-01, cwd '/tmp/a'."], {
includeClaudeCodeInstruction: true,
cacheControl: { type: "ephemeral" },
});
const runB = buildAnthropicSystemBlocks([staticInstructions, "PROJECT\nToday is 2026-08-02, cwd '/tmp/b'."], {
includeClaudeCodeInstruction: true,
cacheControl: { type: "ephemeral" },
});
for (const blocks of [runA, runB]) {
expect(blocks).toHaveLength(4);
expect(blocks?.[0].cache_control).toBeUndefined();
expect(blocks?.[1].cache_control).toBeUndefined();
// The static block carries a breakpoint whose prefix excludes the
// volatile footer, so a cwd/date change reuses it instead of
// re-writing the whole system cache.
expect(blocks?.[2].text).toBe(staticInstructions);
expect(blocks?.[2].cache_control).toEqual({ type: "ephemeral" });
expect(blocks?.[3].cache_control).toEqual({ type: "ephemeral" });
}
expect(runA?.[2].text).toBe(runB?.[2].text);
});
it("caches before the project footer when active-repo context follows it (#7324)", () => {
const staticInstructions = "STATIC INSTRUCTIONS BLOCK";
const projectFooter = "PROJECT\nToday is 2026-08-01, cwd '/tmp'.";
const activeRepoContext = "The active repository is './repo'.";
const blocks = buildAnthropicSystemBlocks([staticInstructions, projectFooter, activeRepoContext], {
includeClaudeCodeInstruction: true,
cacheControl: { type: "ephemeral" },
});
// blocks: [billing, CC identity, static, project footer, active-repo context]
expect(blocks).toHaveLength(5);
expect(blocks?.[0].cache_control).toBeUndefined();
expect(blocks?.[1].cache_control).toBeUndefined();
expect(blocks?.[2]).toEqual({
type: "text",
text: staticInstructions,
cache_control: { type: "ephemeral" },
});
expect(blocks?.[3]).toEqual({
type: "text",
text: projectFooter,
cache_control: { type: "ephemeral" },
});
expect(blocks?.[4]).toEqual({
type: "text",
text: activeRepoContext,
cache_control: { type: "ephemeral" },
});
});
it("caches Claude Code context and the last user block in OAuth request payloads", async () => {
it("places a short breakpoint only on the trailing message in a one-message OAuth request", async () => {
const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, {
systemPrompt: ["Stay concise."],
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
@@ -355,12 +271,11 @@ describe("Anthropic request fingerprint alignment", () => {
expect(payload.system?.[0]?.cache_control).toBeUndefined();
expect(payload.system?.[1]?.text).toBe(claudeCodeSystemInstruction);
expect(payload.system?.[1]?.cache_control).toBeUndefined();
expect(payload.system?.[2]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
expect(payload.system?.[2]?.cache_control).toBeUndefined();
const content = payload.messages?.[0]?.content;
expect(Array.isArray(content)).toBe(true);
expect(Array.isArray(content) ? content[0]?.cache_control : undefined).toEqual({
type: "ephemeral",
ttl: "1h",
});
});
@@ -380,7 +295,6 @@ describe("Anthropic request fingerprint alignment", () => {
const content = payload.messages?.[0]?.content;
expect(Array.isArray(content) ? content[0]?.cache_control : undefined).toEqual({
type: "ephemeral",
ttl: "1h",
});
});
@@ -416,15 +330,23 @@ describe("Anthropic request fingerprint alignment", () => {
timestamp: Date.now(),
},
],
})) as { messages?: Array<{ content?: Array<{ type?: string; cache_control?: unknown }> | string }> };
})) as {
system?: Array<{ cache_control?: unknown }>;
messages?: Array<{ content?: Array<{ type?: string; cache_control?: unknown }> | string }>;
};
expect(payload.system?.some(block => block.cache_control != null)).toBe(false);
const messages = payload.messages ?? [];
const lastContent = messages[messages.length - 1]?.content;
expect(Array.isArray(lastContent)).toBe(true);
expect(Array.isArray(lastContent) ? lastContent[0]?.type : undefined).toBe("tool_result");
expect(Array.isArray(lastContent) ? lastContent[0]?.cache_control : undefined).toEqual({
expect(messages[0]?.content).toBe("Use the tool");
const assistantContent = messages.at(-2)?.content;
expect(Array.isArray(assistantContent) ? assistantContent.at(-1)?.type : undefined).toBe("tool_use");
expect(Array.isArray(assistantContent) ? assistantContent.at(-1)?.cache_control : undefined).toEqual({
type: "ephemeral",
});
const lastContent = messages.at(-1)?.content;
expect(Array.isArray(lastContent) ? lastContent.at(-1)?.type : undefined).toBe("tool_result");
expect(Array.isArray(lastContent) ? lastContent.at(-1)?.cache_control : undefined).toEqual({
type: "ephemeral",
ttl: "1h",
});
});
@@ -504,7 +426,7 @@ describe("Anthropic request fingerprint alignment", () => {
expect(last?.content).toBe("Continue.");
});
it("caches the real assistant before a synthetic Continue pad when the breakpoint budget is tight", async () => {
it("caches the last two real messages and ignores a synthetic Continue pad", async () => {
const assistant: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "real assistant answer" }],
@@ -534,11 +456,14 @@ describe("Anthropic request fingerprint alignment", () => {
messages?: Array<{ role: string; content: string | Array<{ cache_control?: unknown }> }>;
};
expect(payload.system?.filter(block => block.cache_control != null)).toHaveLength(3);
expect(payload.system?.some(block => block.cache_control != null)).toBe(false);
const userContent = payload.messages?.[0]?.content;
expect(Array.isArray(userContent) ? userContent[0]?.cache_control : undefined).toEqual({
type: "ephemeral",
});
const assistantContent = payload.messages?.find(message => message.role === "assistant")?.content;
expect(Array.isArray(assistantContent) ? assistantContent[0]?.cache_control : undefined).toEqual({
type: "ephemeral",
ttl: "1h",
});
const pad = payload.messages?.at(-1);
expect(pad?.content).toBe("Continue.");
@@ -635,7 +560,7 @@ describe("Anthropic request fingerprint alignment", () => {
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
},
});
let capturedParams: MessageCreateParamsStreaming | undefined;
let capturedParams: MessageCreateParams | undefined;
let capturedOptions: { headers?: Record<string, string> } | undefined;
await streamAnthropic(
adaptiveModel,
@@ -673,7 +598,7 @@ describe("Anthropic request fingerprint alignment", () => {
expect(capturedOptions?.headers?.["anthropic-beta"] ?? "").toContain("effort-2025-11-24");
});
it("adds the extended-cache-ttl beta to API-key requests that default to 1h caching", async () => {
it("adds the extended-cache-ttl beta only when 1h caching is requested", async () => {
const captureBeta = () => {
let captured: string | undefined;
const fetchMock = (async (_input: string | URL | Request, init?: RequestInit) => {
@@ -690,18 +615,25 @@ describe("Anthropic request fingerprint alignment", () => {
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
};
const canonical = captureBeta();
const short = captureBeta();
await streamAnthropic(ANTHROPIC_MODEL, cacheContext, {
apiKey: "sk-ant-api-test",
fetch: canonical.fetchMock,
fetch: short.fetchMock,
}).result();
expect(canonical.beta()).toContain("extended-cache-ttl-2025-04-11");
expect(short.beta()).not.toContain("extended-cache-ttl-2025-04-11");
const long = captureBeta();
await streamAnthropic(ANTHROPIC_MODEL, cacheContext, {
apiKey: "sk-ant-api-test",
cacheRetention: "long",
fetch: long.fetchMock,
}).result();
expect(long.beta()).toContain("extended-cache-ttl-2025-04-11");
// Endpoints without long-cache support never send `ttl: "1h"`, so the
// companion beta must stay off the wire too.
const proxy = captureBeta();
await streamAnthropic(UMANS_ANTHROPIC_MODEL, cacheContext, {
apiKey: "sk-umans-test",
cacheRetention: "long",
fetch: proxy.fetchMock,
}).result();
expect(proxy.beta()).not.toContain("extended-cache-ttl-2025-04-11");
@@ -832,7 +764,7 @@ describe("Anthropic request fingerprint alignment", () => {
expect(extractSuffix(billingWithDev)).toBe(extractSuffix(billingUserOnly));
});
it("caches the trailing and stable-prefix system blocks on API-key requests (#7324)", async () => {
it("leaves system blocks uncached on API-key requests", async () => {
const payload = (await captureAnthropicPayload(
ANTHROPIC_MODEL,
{
@@ -843,10 +775,8 @@ describe("Anthropic request fingerprint alignment", () => {
)) as { system?: Array<{ type: string; text?: string; cache_control?: unknown }> };
expect(payload.system).toEqual([
// Stable-prefix breakpoint: reused when the trailing block changes.
{ type: "text", text: "stable system", cache_control: { type: "ephemeral", ttl: "1h" } },
// Canonical Anthropic API-key requests default to the 1h breakpoint.
{ type: "text", text: "stable durable context", cache_control: { type: "ephemeral", ttl: "1h" } },
{ type: "text", text: "stable system" },
{ type: "text", text: "stable durable context" },
]);
});
@@ -0,0 +1,287 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { streamSimple } from "@oh-my-pi/pi-ai";
import type { MessageCreateParams } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
import type { Context, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
const CACHE_REFRESH_DELAY_MS = 5 * 60_000 - 15_000;
const CACHE_TOKENS = 1_200;
const model: Model<"anthropic-messages"> = buildModel({
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: false,
input: ["text"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200_000,
maxTokens: 8_192,
});
const thinkingModel: Model<"anthropic-messages"> = buildModel({
...model,
reasoning: true,
});
const context: Context = {
messages: [{ role: "user", content: "Keep this prefix warm.", timestamp: 1 }],
};
type ResponseMode = "ordinary-write" | "ordinary-roll" | "refresh-read" | "thinking-refresh";
interface FetchCapture {
bodies: MessageCreateParams[];
thinkingRefreshAborted: boolean;
}
const stateMaps: Array<Map<string, ProviderSessionState>> = [];
function createProviderSessionState(): Map<string, ProviderSessionState> {
const states = new Map<string, ProviderSessionState>();
stateMaps.push(states);
return states;
}
function sseResponse(events: Array<Record<string, unknown>>): Response {
const body = `${events.map(event => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "request-id": "req_cache_refresh" },
});
}
function usage(cacheRead: number, cacheWrite: number, output: number): Record<string, unknown> {
return {
input_tokens: 0,
output_tokens: output,
cache_read_input_tokens: cacheRead,
cache_creation_input_tokens: cacheWrite,
cache_creation: {
ephemeral_5m_input_tokens: cacheWrite,
ephemeral_1h_input_tokens: 0,
},
};
}
function ordinaryResponse(mode: "ordinary-write" | "ordinary-roll"): Response {
const cacheRead = mode === "ordinary-roll" ? CACHE_TOKENS : 0;
return sseResponse([
{
type: "message_start",
message: {
id: "msg_ordinary",
usage: usage(cacheRead, CACHE_TOKENS, 0),
},
},
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } },
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: usage(cacheRead, CACHE_TOKENS, 1),
},
{ type: "message_stop" },
]);
}
function refreshResponse(): Response {
return new Response(
JSON.stringify({
id: "msg_refresh",
type: "message",
role: "assistant",
model: model.id,
content: [],
stop_reason: "end_turn",
usage: usage(CACHE_TOKENS, 0, 0),
}),
{
status: 200,
headers: { "Content-Type": "application/json", "request-id": "req_cache_refresh" },
},
);
}
function thinkingRefreshResponse(signal: AbortSignal | null | undefined, capture: FetchCapture): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
const events = [
{
type: "message_start",
message: { id: "msg_thinking_refresh", usage: usage(CACHE_TOKENS, 0, 0) },
},
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: "", signature: "" },
},
];
controller.enqueue(
encoder.encode(
`${events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}`).join("\n\n")}\n\n`,
),
);
const closeOnAbort = () => {
capture.thinkingRefreshAborted = true;
controller.close();
};
if (signal?.aborted) closeOnAbort();
else signal?.addEventListener("abort", closeOnAbort, { once: true });
},
cancel() {
capture.thinkingRefreshAborted = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream", "request-id": "req_thinking_refresh" },
});
}
function createFetch(modes: ResponseMode[], capture: FetchCapture): FetchImpl {
return async (input, init) => {
const body: MessageCreateParams = JSON.parse(String(init?.body ?? "{}"));
capture.bodies.push(body);
const mode = modes[capture.bodies.length - 1];
switch (mode) {
case "ordinary-write":
case "ordinary-roll":
return ordinaryResponse(mode);
case "refresh-read":
return refreshResponse();
case "thinking-refresh":
return thinkingRefreshResponse(input instanceof Request ? input.signal : init?.signal, capture);
}
};
}
interface FinishRequestOptions {
anthropicCacheRefresh?: boolean;
model?: Model<"anthropic-messages">;
sessionId?: string;
}
async function finishRequest(
fetch: FetchImpl,
providerSessionState: Map<string, ProviderSessionState>,
options: FinishRequestOptions = {},
): Promise<void> {
const requestModel = options.model ?? model;
const stream = streamSimple(requestModel, context, {
fetch,
anthropicCacheRefresh: options.anthropicCacheRefresh ?? true,
providerSessionState,
sessionId: options.sessionId ?? "cache-refresh-test-session",
});
for await (const _event of stream) {
// Drain the public response before the idle gap begins.
}
await stream.result();
}
async function drainUntil(predicate: () => boolean, message: string): Promise<void> {
for (let attempt = 0; attempt < 1_000; attempt++) {
if (predicate()) return;
await Promise.resolve();
}
throw new Error(message);
}
async function advanceToRefresh(capture: FetchCapture, expectedRequests: number): Promise<void> {
vi.advanceTimersByTime(CACHE_REFRESH_DELAY_MS);
await drainUntil(
() => capture.bodies.length >= expectedRequests,
`Expected ${expectedRequests} Anthropic requests, saw ${capture.bodies.length}`,
);
}
afterEach(() => {
for (const states of stateMaps.splice(0)) {
for (const state of states.values()) state.close();
states.clear();
}
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("Anthropic prompt-cache refresh", () => {
it("replays max_tokens=0 once per interval and stops after three refreshes", async () => {
vi.useFakeTimers();
const capture: FetchCapture = { bodies: [], thinkingRefreshAborted: false };
const fetch = createFetch(["ordinary-write", "refresh-read", "refresh-read", "refresh-read"], capture);
const states = createProviderSessionState();
await finishRequest(fetch, states);
for (let requestCount = 2; requestCount <= 4; requestCount++) {
await advanceToRefresh(capture, requestCount);
}
vi.advanceTimersByTime(CACHE_REFRESH_DELAY_MS * 2);
await Promise.resolve();
expect(capture.bodies).toHaveLength(4);
for (const refresh of capture.bodies.slice(1)) {
expect(refresh.max_tokens).toBe(0);
expect(refresh.stream).toBe(false);
}
});
it("resets the idle gap when another normal request starts", async () => {
vi.useFakeTimers();
const capture: FetchCapture = { bodies: [], thinkingRefreshAborted: false };
const fetch = createFetch(["ordinary-write", "ordinary-roll", "refresh-read"], capture);
const states = createProviderSessionState();
await finishRequest(fetch, states);
vi.advanceTimersByTime(CACHE_REFRESH_DELAY_MS - 1);
await finishRequest(fetch, states);
vi.advanceTimersByTime(CACHE_REFRESH_DELAY_MS - 1);
await Promise.resolve();
expect(capture.bodies).toHaveLength(2);
vi.advanceTimersByTime(1);
await drainUntil(() => capture.bodies.length === 3, "Replacement idle timer did not refresh");
expect(capture.bodies).toHaveLength(3);
});
it("keeps refresh ownership with the main turn when a side request shares provider state", async () => {
vi.useFakeTimers();
const capture: FetchCapture = { bodies: [], thinkingRefreshAborted: false };
const fetch = createFetch(["ordinary-write", "ordinary-roll", "refresh-read"], capture);
const states = createProviderSessionState();
const halfInterval = Math.floor(CACHE_REFRESH_DELAY_MS / 2);
await finishRequest(fetch, states);
vi.advanceTimersByTime(halfInterval);
await finishRequest(fetch, states, {
anthropicCacheRefresh: false,
sessionId: "cache-refresh-test-session:side:1",
});
vi.advanceTimersByTime(CACHE_REFRESH_DELAY_MS - halfInterval);
await drainUntil(() => capture.bodies.length === 3, "Main idle timer did not refresh");
vi.advanceTimersByTime(halfInterval);
await Promise.resolve();
expect(capture.bodies).toHaveLength(3);
});
it("treats omitted adaptive thinking as active and aborts at the first generated block", async () => {
vi.useFakeTimers();
const capture: FetchCapture = { bodies: [], thinkingRefreshAborted: false };
const fetch = createFetch(["ordinary-write", "thinking-refresh"], capture);
const states = createProviderSessionState();
await finishRequest(fetch, states, { model: thinkingModel });
await advanceToRefresh(capture, 2);
await drainUntil(() => capture.thinkingRefreshAborted, "Thinking refresh was not aborted");
expect(capture.bodies[1]?.thinking).toBeUndefined();
expect(capture.bodies[1]?.output_config?.effort).toBe("low");
expect(capture.bodies[1]?.max_tokens).toBeGreaterThan(0);
expect(capture.bodies[1]?.stream).toBe(true);
expect(capture.thinkingRefreshAborted).toBe(true);
});
});
@@ -1735,7 +1735,7 @@ describe("anthropic stream envelope handling", () => {
expect(cacheControls[2]).toEqual({ type: "ephemeral" });
});
it("defaults API-key requests to 1h cache TTL where long retention is supported", async () => {
it("defaults Anthropic requests to 5m writes and keeps 1h retention opt-in", async () => {
type CapturedParams = { messages: Array<{ content: unknown }> };
const payloads: CapturedParams[] = [];
vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation((params: unknown) => {
@@ -1758,7 +1758,7 @@ describe("anthropic stream envelope handling", () => {
await drain(model);
await drain(proxyModel);
await withEnv({ PI_CACHE_RETENTION: "short" }, () => drain(model));
await withEnv({ PI_CACHE_RETENTION: "long" }, () => drain(model));
const cacheControls = payloads.map(payload => {
const content = payload.messages.at(-1)?.content;
@@ -1766,13 +1766,8 @@ describe("anthropic stream envelope handling", () => {
const lastBlock: { cache_control?: { ttl?: string; type: string } } | undefined = content.at(-1);
return lastBlock?.cache_control;
});
// Agent sessions idle past 5 minutes on background jobs; the canonical
// Anthropic API defaults to the 1h breakpoint so resume doesn't cold-miss
// the whole prefix.
expect(cacheControls[0]).toEqual({ type: "ephemeral", ttl: "1h" });
// Endpoints without long-cache support keep the plain 5m breakpoint.
expect(cacheControls[0]).toEqual({ type: "ephemeral" });
expect(cacheControls[1]).toEqual({ type: "ephemeral" });
// PI_CACHE_RETENTION=short opts back out of the 1h default.
expect(cacheControls[2]).toEqual({ type: "ephemeral" });
expect(cacheControls[2]).toEqual({ type: "ephemeral", ttl: "1h" });
});
});
+2
View File
@@ -5,9 +5,11 @@
### Added
- Added first-party OpenAI Daybreak Blue, Daybreak Red, and GPT-5.6 Cyber models with their documented API pricing, token limits, tools, and full `off`/`low`/`medium`/`high`/`xhigh`/`max` reasoning controls.
- Added `calculateUncachedInputCost()` for pricing a prompt against its active context-length tier without prompt caching.
### Fixed
- Fixed Anthropic cache-write pricing to honor mixed 5-minute and 1-hour TTL usage instead of charging every write at the 5-minute rate.
- Fixed Ollama Cloud DeepSeek V4 Flash (including dated/preview tags like `deepseek-v4-flash:0731`) exposing the generic `minimal`/`low`/`medium`/`high`/`xhigh` effort ladder without `max`; the `ollama-chat` transport now applies the DeepSeek effort contract (Flash → `low`/`high`/`max`, V4 Pro and older reasoners → `high`/`max`), matching the direct API and every other host ([#8334](https://github.com/can1357/oh-my-pi/issues/8334)).
- Bounded OpenAI-compatible model discovery with a default request timeout so a stalled provider `/models` endpoint can no longer hang startup indefinitely in `resolveModelDiscoveryFallback` ([#8315](https://github.com/can1357/oh-my-pi/issues/8315)).
- Fixed Codex-discovered `gpt-daybreak-*` aliases being treated as unknown models, restoring the GPT-5.6 `low`/`medium`/`high`/`xhigh`/`max` effort ladder and its 372K fallback only when the Codex registry omits `context_window`.
+15 -10
View File
@@ -42,18 +42,23 @@ export function getBundledModels(provider: GeneratedProvider): Model<Api>[] {
const models = getProviderModels(provider);
return models ? (Array.from(models.values()) as Model<Api>[]) : [];
}
function resolveTokenCost(cost: Model["cost"], usage: Usage): TokenCost {
function resolveTokenCost(cost: Model["cost"], promptInputTokens: number): TokenCost {
const longContext = cost.longContext;
if (!longContext) return cost;
const orchestration = usage.orchestration;
const promptInputTokens =
usage.input + usage.cacheRead + usage.cacheWrite + (orchestration?.input ?? 0) + (orchestration?.cacheRead ?? 0);
return promptInputTokens > longContext.inputThreshold ? longContext : cost;
}
/** Price a prompt as fully uncached input under its active context-length tier. */
export function calculateUncachedInputCost(cost: Model["cost"], promptInputTokens: number): number {
const rates = resolveTokenCost(cost, promptInputTokens);
return (rates.input / 1_000_000) * promptInputTokens;
}
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
const rates = resolveTokenCost(model.cost, usage);
const orchestration = usage.orchestration;
const promptInputTokens =
usage.input + usage.cacheRead + usage.cacheWrite + (orchestration?.input ?? 0) + (orchestration?.cacheRead ?? 0);
const rates = resolveTokenCost(model.cost, promptInputTokens);
usage.cost.input = (rates.input / 1000000) * (usage.input + (orchestration?.input ?? 0));
usage.cost.output = (rates.output / 1000000) * (usage.output + (orchestration?.output ?? 0));
usage.cost.cacheRead = (rates.cacheRead / 1000000) * (usage.cacheRead + (orchestration?.cacheRead ?? 0));
@@ -66,11 +71,11 @@ export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage
* Price cache-write tokens, honoring the TTL breakdown when the provider reports one.
*
* `rates.cacheWrite` is the 5-minute write rate (Anthropic bills 5m writes at
* 1.25x base input). When `usage.cttl` is present the write mixes 5m and 1h
* breakpoints — omp defaults to 1h retention on first-party Anthropic, and 1h writes
* bill at 2x base input — so each component is priced at its own rate instead of the
* flat 5m rate. Deriving 1h from `input * 2` (Anthropic's published multiplier) is
* model-independent and stays correct even for legacy entries whose stored
* 1.25x base input). When `usage.cttl` is present the write can mix 5m and 1h
* breakpoints, and 1h writes bill at 2x base input, so each component is
* priced at its own rate instead of the flat 5m rate. Deriving 1h from
* `input * 2` (Anthropic's published multiplier) is model-independent and
* stays correct even for legacy entries whose stored
* `cacheWrite` scalar drifts from 1.25x input. Providers that omit `cttl`
* (everyone but Anthropic) keep the flat-rate calculation.
*
+2
View File
@@ -6,11 +6,13 @@
- Added Astral `ty` as a built-in Python primary LSP server (`ty server`), ordered behind `pyright`/`basedpyright`/`pylsp` so it becomes the primary Python LSP only when the existing servers are unavailable. `ruff` remains the Python linter and coexists alongside `ty` ([#4617](https://github.com/can1357/oh-my-pi/issues/4617)).
- Added first-party Nix support with reproducible source builds for Linux and macOS on x86-64 and ARM64, a pinned development shell, an overlay, NixOS and Home Manager modules, offline Bun dependencies, and lightweight flake evaluation in CI. Nix-managed installs now direct updates back through Nix instead of replacing store-managed executables.
- `omp update` and the startup version check now follow an `omp.rename` pointer in the published npm manifest, preparing existing installs for the upcoming npm package rename. Migration is transactional: the renamed agent/natives packages are installed first (npm uses `--force` to take over the `omp` bin), so an install failure leaves the old install untouched; the old-name globals are removed only afterwards, and a broken bin link is restored by re-running the idempotent install before verification decides the outcome.
### Changed
- `/usage`, `omp usage`, and the status line now show authoritative OpenCode Go quota from the official `GET /zen/go/v1/usage` endpoint — including usage made outside OMP — instead of dollar estimates summed from OMP-observed request costs. The status line renders all three windows (`5h` / `7d` / `mo`), and the per-turn cost recording special case for `opencode-go` sessions is gone along with the "OMP-observed spend only" disclaimer ([#8337](https://github.com/can1357/oh-my-pi/pull/8337) by [@will-bogusz](https://github.com/will-bogusz)).
- Clarified that the production collab relay source and binaries are not currently published, and documented the source-available local protocol relay ([#8165](https://github.com/can1357/oh-my-pi/issues/8165)).
- Enabled bounded Anthropic prompt-cache refreshes for the main agent loop while keeping advisor and side-channel requests from taking over the shared refresh timer.
- Fixed snapcompact compaction shipping its redundant frame archive out of `SessionMaintenance.compact()` on both the manual RPC response (which hard-failed protocol v1 with a transport error after the compaction had already persisted) and the `auto_compaction_end` event payload (which forced the shrink ladder on every unattended pass); the archive is now stripped from both exits while the persisted compaction entry keeps it ([#8168](https://github.com/can1357/oh-my-pi/issues/8168)).
- Fixed the edit tool showing no diff preview in `apply_patch` mode: the built-in `edit` tool presents on the wire as `apply_patch`, but the renderer-provenance gate did not resolve that alias to its built-in owner, so the edit renderer was skipped ([#8184](https://github.com/can1357/oh-my-pi/issues/8184)).
+2 -1
View File
@@ -170,6 +170,7 @@ async function printStatsSummary(): Promise<void> {
console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
console.log(` Cache Savings: ${formatPercent(overall.cacheSavings)}`);
console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
@@ -182,7 +183,7 @@ async function printStatsSummary(): Promise<void> {
console.log(chalk.bold("\nBy Model:"));
for (const m of byModel.slice(0, 10)) {
console.log(
` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache rate, ${formatPercent(m.cacheSavings)} cache savings`,
);
}
}
+237 -34
View File
@@ -67,11 +67,27 @@ function currentNativeTag(): string {
/** Distribution channel advertised by a release's published npm manifest. */
export type ReleaseDist = "npm" | "binary";
interface ReleaseInfo {
/** npm package names a release installs: the agent package and its natives companion. */
export interface ReleasePackages {
pkg: string;
natives: string;
}
/** Parsed `omp.rename` pointer: the new agent package name and optional new natives name. */
export interface ReleaseRename {
pkg: string;
natives?: string;
}
const CURRENT_PACKAGES: ReleasePackages = { pkg: PACKAGE, natives: NATIVES_PACKAGE };
export interface ReleaseInfo {
tag: string;
version: string;
/** Parsed `omp.dist` from the registry manifest; undefined when absent. */
dist?: ReleaseDist;
/** npm names to install, resolved after following any `omp.rename` pointers. */
packages: ReleasePackages;
}
export interface ReleaseBinaryAsset {
@@ -103,6 +119,34 @@ export function resolveReleaseDist(manifest: unknown): ReleaseDist | undefined {
return dist === "npm" ? "npm" : "binary";
}
/**
* Parse the `omp.rename` pointer from a published package manifest.
*
* Forward-compatibility contract for renaming the npm package: the final
* version published under an old name is a stub whose manifest carries
* `"omp": { "rename": { "package": "<new-agent-pkg>", "natives": "<new-natives-pkg>" }, "dist": "binary" }`.
* Updaters that understand `rename` follow the pointer and resolve the
* release from the renamed package instead ({@link getLatestRelease});
* older deployed updaters ignore it and take the `dist: "binary"` escape
* hatch, replacing the install with the GitHub release binary rather than
* installing the stub via bun/npm.
*
* The renamed package's own manifest MUST declare `"dist": "npm"` (so
* package-manager installs stay package-managed across a major bump) and
* MUST continue the old version line (a version reset would compare as
* "already up to date" against the running build).
*/
export function resolveReleaseRename(manifest: unknown): ReleaseRename | undefined {
if (!isRecord(manifest) || !isRecord(manifest.omp)) return undefined;
const rename = manifest.omp.rename;
if (!isRecord(rename) || typeof rename.package !== "string" || rename.package.length === 0) return undefined;
const natives = rename.natives;
return {
pkg: rename.package,
natives: typeof natives === "string" && natives.length > 0 ? natives : undefined,
};
}
function majorVersion(version: string): number {
const major = Number.parseInt(version, 10);
return Number.isNaN(major) ? 0 : major;
@@ -529,36 +573,63 @@ async function resolveUpdateTarget(options: { allowPackageManagers: boolean }):
throw new Error(`Could not resolve ${APP_NAME} binary path in PATH`);
}
/**
* Get the latest release info from the npm registry.
* Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
*/
async function getLatestRelease(): Promise<ReleaseInfo> {
/** Bound on `omp.rename` hops so a broken pointer chain cannot loop forever. */
const MAX_RENAME_HOPS = 3;
async function fetchLatestManifest(
pkg: string,
timeoutMs: number,
): Promise<{ version: string; manifest: Record<string, unknown> }> {
let response: Response;
try {
response = await fetch(`${NPM_REGISTRY}${PACKAGE}/latest`, {
signal: withTimeoutSignal(RELEASE_METADATA_TIMEOUT_MS),
response = await fetch(`${NPM_REGISTRY}${pkg}/latest`, {
signal: withTimeoutSignal(timeoutMs),
});
} catch (err) {
if (isTimeoutError(err)) {
throw new Error("Timed out fetching release info after 30s", { cause: err });
throw new Error(`Timed out fetching release info for ${pkg} after ${Math.round(timeoutMs / 1000)}s`, {
cause: err,
});
}
throw err;
}
if (!response.ok) {
throw new Error(`Failed to fetch release info: ${response.statusText}`);
throw new Error(`Failed to fetch release info for ${pkg}: ${response.statusText}`);
}
const data: unknown = await response.json();
if (!isRecord(data) || typeof data.version !== "string") {
throw new Error("Malformed npm registry response: missing version");
throw new Error(`Malformed npm registry response for ${pkg}: missing version`);
}
return { version: data.version, manifest: data };
}
/**
* Get the latest release info from the npm registry, following `omp.rename`
* pointers ({@link resolveReleaseRename}) when the package has moved to a new
* npm name. Version, dist, and install names all come from the final manifest
* in the chain. Uses npm instead of GitHub API to avoid unauthenticated rate
* limiting.
*/
export async function getLatestRelease(options: { timeoutMs?: number } = {}): Promise<ReleaseInfo> {
const timeoutMs = options.timeoutMs ?? RELEASE_METADATA_TIMEOUT_MS;
const packages: ReleasePackages = { ...CURRENT_PACKAGES };
const visited = new Set([packages.pkg]);
let latest = await fetchLatestManifest(packages.pkg, timeoutMs);
for (let hop = 0; hop < MAX_RENAME_HOPS; hop++) {
const rename = resolveReleaseRename(latest.manifest);
if (!rename || visited.has(rename.pkg)) break;
visited.add(rename.pkg);
packages.pkg = rename.pkg;
if (rename.natives) packages.natives = rename.natives;
latest = await fetchLatestManifest(packages.pkg, timeoutMs);
}
const version = data.version;
return {
tag: `v${version}`,
version,
dist: resolveReleaseDist(data),
tag: `v${latest.version}`,
version: latest.version,
dist: resolveReleaseDist(latest.manifest),
packages,
};
}
@@ -994,10 +1065,14 @@ export async function replaceBinaryForUpdate(options: BinaryReplacementOptions):
}
}
function buildVersionedPackageInstallArgs(expectedVersion: string, nativeTag: string): string[] {
const args = [`${PACKAGE}@${expectedVersion}`, `${NATIVES_PACKAGE}@${expectedVersion}`];
function buildVersionedPackageInstallArgs(
expectedVersion: string,
nativeTag: string,
packages: ReleasePackages,
): string[] {
const args = [`${packages.pkg}@${expectedVersion}`, `${packages.natives}@${expectedVersion}`];
if (SUPPORTED_NATIVE_TAGS.has(nativeTag)) {
args.push(`${NATIVES_PACKAGE}-${nativeTag}@${expectedVersion}`);
args.push(`${packages.natives}-${nativeTag}@${expectedVersion}`);
}
return args;
}
@@ -1032,25 +1107,41 @@ function buildVersionedPackageInstallArgs(expectedVersion: string, nativeTag: st
* the original "no matching version" message instead of `EBADPLATFORM`.
* See #1824.
*/
export function buildBunInstallArgs(expectedVersion: string, nativeTag: string = currentNativeTag()): string[] {
export function buildBunInstallArgs(
expectedVersion: string,
nativeTag: string = currentNativeTag(),
packages: ReleasePackages = CURRENT_PACKAGES,
): string[] {
return [
"install",
"-g",
"--no-cache",
`--registry=${NPM_REGISTRY}`,
...buildVersionedPackageInstallArgs(expectedVersion, nativeTag),
...buildVersionedPackageInstallArgs(expectedVersion, nativeTag, packages),
];
}
/** Build the npm argv used to update npm-managed global installs. */
export function buildNpmInstallArgs(expectedVersion: string, nativeTag: string = currentNativeTag()): string[] {
const args = [
/**
* Build the npm argv used to update npm-managed global installs.
*
* `force` is set only for rename migrations: npm refuses to write the `omp`
* bin while the old package still owns it (`EEXIST`), and the migration
* installs the new package BEFORE removing the old one so a failed install
* never leaves the user without a working `omp`.
*/
export function buildNpmInstallArgs(
expectedVersion: string,
nativeTag: string = currentNativeTag(),
packages: ReleasePackages = CURRENT_PACKAGES,
flags: { force?: boolean } = {},
): string[] {
return [
"install",
"-g",
...(flags.force ? ["--force"] : []),
`--registry=${NPM_REGISTRY}`,
...buildVersionedPackageInstallArgs(expectedVersion, nativeTag),
...buildVersionedPackageInstallArgs(expectedVersion, nativeTag, packages),
];
return args;
}
export function buildHomebrewUpdateArgs(force: boolean): string[] {
@@ -1065,18 +1156,123 @@ export function buildMiseForceInstallArgs(expectedVersion: string): string[] {
return ["install", "--force", `${MISE_TOOL}@${expectedVersion}`];
}
/**
* Old-name globals a rename migration removes after the new install exists:
* the set difference between the old install's top-level globals
* ({@link buildVersionedPackageInstallArgs} installs the agent, natives core,
* and platform leaf explicitly) and the resolved install's. An agent-only
* rename keeps the natives names, and removing them would strip the addon
* the new install just pinned.
*/
export function buildRenameCleanupPackages(
packages: ReleasePackages,
nativeTag: string = currentNativeTag(),
): string[] {
const old = [PACKAGE, NATIVES_PACKAGE];
if (SUPPORTED_NATIVE_TAGS.has(nativeTag)) {
old.push(`${NATIVES_PACKAGE}-${nativeTag}`);
}
const newLeaf = `${packages.natives}-${nativeTag}`;
return old.filter(name => name !== packages.pkg && name !== packages.natives && name !== newLeaf);
}
/** Injectable shell steps for {@link migrateRenamedInstall}; commands return process exit codes. */
export interface RenameMigrationSteps {
/** Globally install the new package names. MUST be idempotent: re-running re-links the `omp` bin. */
install(): Promise<number>;
/** Remove the old-name globals. */
removeOld(): Promise<number>;
/** Check the PATH-resolved `omp` against the expected version. */
verify(): Promise<InstalledVersionVerification>;
}
/** Production {@link RenameMigrationSteps}: bun/npm global installs plus PATH verification. */
function packageManagerMigrationSteps(manager: "bun" | "npm", release: ReleaseInfo): RenameMigrationSteps {
const nativeTag = currentNativeTag();
return {
async install() {
if (manager === "bun") {
const args = buildBunInstallArgs(release.version, nativeTag, release.packages);
return (await $`bun ${args}`.nothrow()).exitCode;
}
const args = buildNpmInstallArgs(release.version, nativeTag, release.packages, { force: true });
return (await $`npm ${args}`.nothrow()).exitCode;
},
async removeOld() {
// One invocation per package: a single batched remove fails wholesale
// when any name is absent (e.g. the platform leaf on an old install),
// which would skip the agent package that actually owns the bin.
let agentExit = 0;
for (const pkg of buildRenameCleanupPackages(release.packages, nativeTag)) {
const result =
manager === "bun"
? await $`bun remove -g ${pkg}`.quiet().nothrow()
: await $`npm uninstall -g ${pkg}`.quiet().nothrow();
if (pkg === PACKAGE) agentExit = result.exitCode;
}
return agentExit;
},
verify: () => verifyInstalledVersion(release.version),
};
}
/**
* Migrate a package-manager install across an `omp.rename` hop without a
* window where no working `omp` exists:
*
* 1. Install the new package FIRST. Nothing has been removed yet, so a
* failure here leaves the old install fully functional.
* 2. Remove the old-name globals. Failure is non-fatal: a stale package
* wastes disk, but the bin already points at the new install.
* 3. Verify the PATH-resolved `omp`. If the removal deleted the shared bin
* link (manager-dependent), re-run the idempotent install to restore it
* and verify again; only a repeated failure aborts, with a recovery hint.
*/
export async function migrateRenamedInstall(release: ReleaseInfo, steps: RenameMigrationSteps): Promise<void> {
console.log(chalk.dim(`npm package renamed to ${release.packages.pkg}; migrating this install.`));
const installExit = await steps.install();
if (installExit !== 0) {
throw new Error(
`install of ${release.packages.pkg} failed with exit code ${installExit}; the existing install was left untouched`,
);
}
const removeExit = await steps.removeOld();
if (removeExit !== 0) {
console.log(chalk.yellow(`Warning: could not remove the old ${PACKAGE} package; remove it manually later.`));
}
let verification = await steps.verify();
if (!verification.ok) {
// Removing the old package may have taken the shared bin link with it;
// reinstalling the new package restores the link.
if ((await steps.install()) === 0) {
verification = await steps.verify();
}
}
if (!verification.ok) {
throw new Error(
`${formatVerificationFailure(verification, release.version)}; reinstall with: curl -fsSL https://omp.sh/install | sh`,
);
}
printVerifiedVersion(release.version);
}
/**
* Update via package manager.
*/
async function updateViaBun(expectedVersion: string): Promise<void> {
async function updateViaBun(release: ReleaseInfo): Promise<void> {
console.log(chalk.dim("Updating via bun..."));
const args = buildBunInstallArgs(expectedVersion);
if (release.packages.pkg !== PACKAGE) {
await migrateRenamedInstall(release, packageManagerMigrationSteps("bun", release));
} else {
const args = buildBunInstallArgs(release.version, currentNativeTag(), release.packages);
const result = await $`bun ${args}`.nothrow();
if (result.exitCode !== 0) {
throw new Error(`bun install failed with exit code ${result.exitCode}`);
}
await printVerification(expectedVersion);
await printVerification(release.version);
}
try {
const pruneResult = await pruneBunCacheAfterGlobalInstall();
if (pruneResult && pruneResult.removedEntries > 0) {
@@ -1087,15 +1283,19 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
}
}
async function updateViaNpm(expectedVersion: string): Promise<void> {
async function updateViaNpm(release: ReleaseInfo): Promise<void> {
console.log(chalk.dim("Updating via npm..."));
const args = buildNpmInstallArgs(expectedVersion);
if (release.packages.pkg !== PACKAGE) {
await migrateRenamedInstall(release, packageManagerMigrationSteps("npm", release));
return;
}
const args = buildNpmInstallArgs(release.version, currentNativeTag(), release.packages);
const result = await $`npm ${args}`.nothrow();
if (result.exitCode !== 0) {
throw new Error(`npm install failed with exit code ${result.exitCode}`);
}
await printVerification(expectedVersion);
await printVerification(release.version);
}
async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise<void> {
@@ -1345,6 +1545,9 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
} else {
console.log(chalk.yellow(`Forcing reinstall of ${release.version}`));
}
if (release.packages.pkg !== PACKAGE) {
console.log(chalk.cyan(`The npm package moved to ${release.packages.pkg}; updating migrates this install.`));
}
if (opts.check) {
// Just check, don't install
@@ -1379,9 +1582,9 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
),
);
} else if (target.method === "bun") {
await updateViaBun(release.version);
await updateViaBun(release);
} else {
await updateViaNpm(release.version);
await updateViaNpm(release);
}
} else {
if (forceBinary && target.replacesSymlink) {
+3 -14
View File
@@ -29,6 +29,7 @@ import { processFileArguments } from "./cli/file-processor";
import { buildInitialMessage } from "./cli/initial-message";
import { selectSession } from "./cli/session-picker";
import { applyStartupCwd } from "./cli/startup-cwd";
import { getLatestRelease } from "./cli/update-cli";
import { findConfigFile } from "./config";
import { ModelRegistry } from "./config/model-registry";
import {
@@ -95,7 +96,6 @@ import { concreteThinkingLevel, parseConfiguredThinkingLevel } from "./thinking"
import type { LspStartupServerInfo } from "./tools";
import { getChangelogPath, resolveStartupChangelogForDisplay, type StartupChangelogSelection } from "./utils/changelog";
import { EventBus } from "./utils/event-bus";
import { withTimeoutSignal } from "./utils/fetch-timeout";
type RunAcpMode = (createSession: AcpSessionFactory) => Promise<never>;
type RunPrintMode = (session: AgentSession, options: PrintModeOptions) => Promise<void>;
@@ -115,19 +115,8 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
return;
}
try {
const response = await fetch("https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest", {
signal: withTimeoutSignal(5_000),
});
if (!response.ok) return undefined;
const data = (await response.json()) as { version?: string };
const latestVersion = data.version;
if (latestVersion && Bun.semver.order(latestVersion, currentVersion) > 0) {
return latestVersion;
}
return undefined;
const release = await getLatestRelease({ timeoutMs: 5_000 });
return Bun.semver.order(release.version, currentVersion) > 0 ? release.version : undefined;
} catch {
return undefined;
}
+1
View File
@@ -3247,6 +3247,7 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
supportsExternalThinking(streamModel);
return settingsAwareStreamFn(streamModel, context, {
...streamOptions,
anthropicCacheRefresh: true,
forceReasoningOff: externalThinking || streamOptions?.forceReasoningOff,
});
},
@@ -130,7 +130,6 @@ function buildSystemBlocks(
return buildAnthropicSystemBlocks(systemPrompt ? [systemPrompt] : undefined, {
includeClaudeCodeInstruction: includeClaudeCode,
extraInstructions,
cacheControl: { type: "ephemeral" },
});
}
@@ -0,0 +1,155 @@
/**
* Real package-manager seam for `omp.rename` migrations.
*
* The unit tests in test/update-cli.test.ts prove the orchestration order of
* migrateRenamedInstall with injected steps; these fixtures prove the two
* empirical assumptions that orchestration stands on, against the actual
* package managers in isolated temp prefixes:
*
* - npm refuses to overwrite a bin owned by another package (EEXIST) and
* `--force` takes ownership of it — and `npm uninstall -g <old>` deletes
* the shared bin even though it points at the new package, so the repair
* reinstall inside migrateRenamedInstall is the NORMAL npm path, not an
* edge case.
* - bun clobbers the bin on install without force, and `bun remove -g <old>`
* re-links the bin to the surviving package.
*
* Each scenario runs the full install-new/remove-old/verify transaction and
* asserts the resulting launcher executes the NEW version.
*/
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $which, removeWithRetries } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import {
type InstalledVersionVerification,
migrateRenamedInstall,
type ReleaseInfo,
type RenameMigrationSteps,
} from "../../src/cli/update-cli";
import { initTheme } from "../../src/modes/theme/theme";
const OLD_PKG = "omp-rename-fixture-old";
const NEW_PKG = "omp-rename-fixture-new";
const OLD_VERSION = "1.0.0";
const NEW_VERSION = "2.0.0";
const tempDirs: string[] = [];
// printVerifiedVersion renders theme glyphs; the update command initializes
// the theme before calling into update-cli, so the tests must too.
beforeAll(async () => {
await initTheme();
});
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map(dir => removeWithRetries(dir)));
});
/** Two installable packages that both expose an `omp` bin, plus an empty prefix. */
async function makeFixtures(): Promise<{ root: string; oldDir: string; newDir: string }> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-rename-itest-"));
tempDirs.push(root);
const mkpkg = async (name: string, version: string): Promise<string> => {
const dir = path.join(root, name);
await Bun.write(path.join(dir, "package.json"), JSON.stringify({ name, version, bin: { omp: "cli.js" } }));
const cli = path.join(dir, "cli.js");
await Bun.write(cli, `#!/usr/bin/env bun\nconsole.log("omp/${version}");\n`);
await fs.chmod(cli, 0o755);
return dir;
};
return { root, oldDir: await mkpkg(OLD_PKG, OLD_VERSION), newDir: await mkpkg(NEW_PKG, NEW_VERSION) };
}
/** Run the installed launcher and parse its reported version, mirroring verifyBinaryAtPath. */
async function verifyLauncher(binDir: string, expectedVersion: string): Promise<InstalledVersionVerification> {
const launcher = path.join(binDir, "omp");
const result = await $`${launcher}`.quiet().nothrow();
if (result.exitCode !== 0) return { ok: false, path: launcher };
const actual = result.text().match(/\/(\d+\.\d+\.\d+)/)?.[1];
return { ok: actual === expectedVersion, actual, path: launcher };
}
const RELEASE: ReleaseInfo = {
tag: `v${NEW_VERSION}`,
version: NEW_VERSION,
packages: { pkg: NEW_PKG, natives: "@oh-my-pi/pi-natives" },
};
describe.skipIf(process.platform === "win32" || !$which("npm"))("rename migration over real npm", () => {
it("takes bin ownership with --force, survives the uninstall deleting the bin, and lands on the new version", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const { root, oldDir, newDir } = await makeFixtures();
const prefix = path.join(root, "npm-prefix");
const binDir = path.join(prefix, "bin");
const env = {
...process.env,
npm_config_cache: path.join(root, "npm-cache"),
npm_config_update_notifier: "false",
npm_config_fund: "false",
npm_config_audit: "false",
};
const seed = await $`npm install -g --prefix ${prefix} ${oldDir}`.env(env).quiet().nothrow();
expect(seed.exitCode).toBe(0);
expect(await verifyLauncher(binDir, OLD_VERSION)).toMatchObject({ ok: true, actual: OLD_VERSION });
// The load-bearing precondition for --force: while the old package owns
// the bin, a plain install of the new package fails instead of clobbering.
const plain = await $`npm install -g --prefix ${prefix} ${newDir}`.env(env).quiet().nothrow();
expect(plain.exitCode).not.toBe(0);
expect(await verifyLauncher(binDir, OLD_VERSION)).toMatchObject({ ok: true, actual: OLD_VERSION });
const steps: RenameMigrationSteps = {
async install() {
return (await $`npm install -g --force --prefix ${prefix} ${newDir}`.env(env).quiet().nothrow()).exitCode;
},
async removeOld() {
return (await $`npm uninstall -g --prefix ${prefix} ${OLD_PKG}`.env(env).quiet().nothrow()).exitCode;
},
verify: () => verifyLauncher(binDir, NEW_VERSION),
};
await migrateRenamedInstall(RELEASE, steps);
expect(await verifyLauncher(binDir, NEW_VERSION)).toMatchObject({ ok: true, actual: NEW_VERSION });
const globalPackages = await fs.readdir(path.join(prefix, "lib", "node_modules"));
expect(globalPackages).toContain(NEW_PKG);
expect(globalPackages).not.toContain(OLD_PKG);
}, 120_000);
});
describe.skipIf(process.platform === "win32")("rename migration over real bun", () => {
it("clobbers the old bin on install, survives removing the old package, and lands on the new version", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const { root, oldDir, newDir } = await makeFixtures();
const binDir = path.join(root, "bun-bin");
await fs.mkdir(binDir, { recursive: true });
const env = {
...process.env,
BUN_INSTALL_GLOBAL_DIR: path.join(root, "bun-global"),
BUN_INSTALL_BIN: binDir,
};
const seed = await $`bun add -g file:${oldDir}`.env(env).quiet().nothrow();
expect(seed.exitCode).toBe(0);
expect(await verifyLauncher(binDir, OLD_VERSION)).toMatchObject({ ok: true, actual: OLD_VERSION });
const steps: RenameMigrationSteps = {
async install() {
return (await $`bun add -g file:${newDir}`.env(env).quiet().nothrow()).exitCode;
},
async removeOld() {
return (await $`bun remove -g ${OLD_PKG}`.env(env).quiet().nothrow()).exitCode;
},
verify: () => verifyLauncher(binDir, NEW_VERSION),
};
await migrateRenamedInstall(RELEASE, steps);
expect(await verifyLauncher(binDir, NEW_VERSION)).toMatchObject({ ok: true, actual: NEW_VERSION });
const globalManifest = await Bun.file(path.join(root, "bun-global", "package.json")).json();
expect(Object.keys(globalManifest.dependencies ?? {})).toEqual([NEW_PKG]);
}, 120_000);
});
+13 -3
View File
@@ -291,13 +291,23 @@ describe("update-cli npm rename contract", () => {
expect(buildNpmInstallArgs("16.3.15", "win32-x64")).not.toContain("--force");
});
it("removes the old agent package and its natives companions, including the supported platform leaf", () => {
expect(buildRenameCleanupPackages("darwin-arm64")).toEqual([
it("removes the old agent package and its natives companions when both names moved", () => {
const packages = { pkg: "@new/omp", natives: "@new/natives" };
expect(buildRenameCleanupPackages(packages, "darwin-arm64")).toEqual([
"@oh-my-pi/pi-coding-agent",
"@oh-my-pi/pi-natives",
"@oh-my-pi/pi-natives-darwin-arm64",
]);
expect(buildRenameCleanupPackages("linux-arm")).toEqual(["@oh-my-pi/pi-coding-agent", "@oh-my-pi/pi-natives"]);
expect(buildRenameCleanupPackages(packages, "linux-arm")).toEqual([
"@oh-my-pi/pi-coding-agent",
"@oh-my-pi/pi-natives",
]);
});
it("keeps the natives packages on an agent-only rename so cleanup cannot strip the addon the new install pinned", () => {
const packages = { pkg: "@new/omp", natives: "@oh-my-pi/pi-natives" };
expect(buildRenameCleanupPackages(packages, "darwin-arm64")).toEqual(["@oh-my-pi/pi-coding-agent"]);
expect(buildRenameCleanupPackages(packages, "linux-arm")).toEqual(["@oh-my-pi/pi-coding-agent"]);
});
});
+8 -6
View File
@@ -11,7 +11,7 @@ import * as fs from "node:fs/promises";
import { createRequire } from "node:module";
import * as path from "node:path";
import { $ } from "bun";
import { detectHostAvx2Support } from "../../../scripts/host-detect";
import { detectHostAvx2Support, resolveLocalHostAddon } from "../../../scripts/host-detect";
import { generateEnumExports } from "./gen-enums";
// pcre2-sys prefers a system libpcre2 when pkg-config finds one. Keep the
@@ -61,10 +61,12 @@ const rustDir = path.join(repoRoot, "crates/pi-natives");
const nativeDir = path.join(import.meta.dir, "../native");
const packageJsonPath = path.join(import.meta.dir, "../package.json");
type X64Variant = "modern" | "baseline";
const effectiveVariant: X64Variant | null =
process.arch === "x64" ? (detectHostAvx2Support() ? "modern" : "baseline") : null;
const localAddon = resolveLocalHostAddon({
platform: process.platform,
arch: process.arch,
avx2: detectHostAvx2Support(),
});
const effectiveVariant = localAddon.x64Variant;
const variantSuffix = effectiveVariant ? `-${effectiveVariant}` : "";
// Pin Rust target-cpu so x64 baseline/modern variants get a reproducible ISA floor
@@ -166,7 +168,7 @@ async function installGeneratedBindings(outputDir: string): Promise<void> {
}
}
const canonicalAddonFilename = `pi_natives.${process.platform}-${process.arch}${variantSuffix}.node`;
const canonicalAddonFilename = localAddon.filename;
const canonicalAddonPath = path.join(nativeDir, canonicalAddonFilename);
console.log(`Building pi-natives bindings for ${process.platform}-${process.arch}${variantSuffix} (local)…`);
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added cost-weighted `cacheSavings` alongside `cacheRate`, including cache-read discounts and 5-minute/1-hour write premiums against the equivalent uncached prompt cost.
### Fixed
- Made the embedded dashboard archive byte-reproducible by sorting entries and zeroing tar and gzip timestamps before it is compiled into OMP.
+2 -1
View File
@@ -15,6 +15,7 @@ Local observability dashboard for AI usage statistics.
|--------|-------------|
| Tokens/s | `output_tokens / (duration / 1000)` |
| Cache Rate | `cache_read / (input + cache_read) * 100` |
| Cache Savings | `(uncached prompt cost - actual prompt cost) / uncached prompt cost * 100` |
| Error Rate | `count(stopReason=error) / total_calls * 100` |
| Total Cost | Sum of `usage.cost.total` |
| Avg Latency | Mean of `duration` |
@@ -71,7 +72,7 @@ console.log(stats.byModel[0].avgTokensPerSecond);
The web dashboard provides:
- Overall metrics cards (requests, cost, cache rate, error rate, duration, tokens/s)
- Overall metrics cards (requests, cost, cache rate, cache savings, error rate, duration, tokens/s)
- Time series chart showing requests and errors over time
- Per-model breakdown table
- Per-folder breakdown table
@@ -322,7 +322,7 @@ function ModelsTable({
<div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
<div className="space-y-4 text-sm">
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
<div className="text-[var(--text-primary)] font-medium mb-2">Efficiency</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Error rate</span>
@@ -336,8 +336,18 @@ function ModelsTable({
</div>
<div className="flex items-center justify-between">
<span>Cache rate</span>
<span className="text-[var(--accent-cyan)]">
{(model.cacheRate * 100).toFixed(1)}%
<span className="font-mono">{(model.cacheRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span>Cache savings</span>
<span
className={
model.cacheSavings < 0
? "text-[var(--accent-red)]"
: "text-[var(--accent-green)]"
}
>
{(model.cacheSavings * 100).toFixed(1)}%
</span>
</div>
</div>
@@ -87,8 +87,16 @@ export function ProjectsRoute({ active, range, refreshTrigger }: ProjectsRoutePr
key: "cacheRate",
header: "Cache Rate",
numeric: true,
render: (item: FolderRowView) => <span className="font-mono">{formatPercent(item.cacheRate)}</span>,
},
{
key: "cacheSavings",
header: "Cache Savings",
numeric: true,
render: (item: FolderRowView) => (
<span className="stats-text-success font-medium">{formatPercent(item.cacheRate)}</span>
<span className={`${item.cacheSavings < 0 ? "stats-text-danger" : "stats-text-success"} font-medium`}>
{formatPercent(item.cacheSavings)}
</span>
),
},
{
@@ -129,9 +137,13 @@ export function ProjectsRoute({ active, range, refreshTrigger }: ProjectsRoutePr
<div className="stats-mobile-card-value font-mono">{formatCost(item.totalCost)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Cache</div>
<div className="stats-mobile-card-label">Cache Rate</div>
<div className="stats-mobile-card-value">{formatPercent(item.cacheRate)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Cache Savings</div>
<div className="stats-mobile-card-value">{formatPercent(item.cacheSavings)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Duration</div>
<div className="stats-mobile-card-value">{formatDurationMs(item.avgDuration)}</div>
+1 -1
View File
@@ -563,7 +563,7 @@
.stats-metric-primary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
+11 -1
View File
@@ -29,7 +29,17 @@ export function MetricCluster({ stats }: MetricClusterProps) {
<div className="stats-metric-label">Requests</div>
<div className="stats-metric-value">{formatInteger(stats.totalRequests)}</div>
</div>
<div className="stats-metric-card primary">
<div
className="stats-metric-card primary"
title="Prompt-input cost saved versus billing the same tokens uncached; cache writes can make this negative"
>
<div className="stats-metric-label">Cache Savings</div>
<div className="stats-metric-value">{formatPercent(stats.cacheSavings)}</div>
</div>
<div
className="stats-metric-card primary"
title="Prompt input served from cache: cache reads / (uncached input + cache reads)"
>
<div className="stats-metric-label">Cache Rate</div>
<div className="stats-metric-value">{formatPercent(stats.cacheRate)}</div>
</div>
+104 -8
View File
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
import * as fs from "node:fs/promises";
import type { Usage } from "@oh-my-pi/pi-ai";
import type { GeneratedProvider } from "@oh-my-pi/pi-catalog/models";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { calculateUncachedInputCost, getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
import { classifyAgentType } from "./parser";
import type {
@@ -33,7 +33,7 @@ import type {
type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
type UsageCost = Usage["cost"];
type CostTokens = Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">;
type CostTokens = Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite" | "orchestration">;
const ZERO_USAGE_COST: UsageCost = {
input: 0,
@@ -53,6 +53,42 @@ interface CostBackfillRow {
cache_write_tokens: number;
}
interface NoCacheInputCostBackfillRow {
id: number;
provider: string;
model: string;
input_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}
interface AggregatedStatsRow {
total_requests: number;
failed_requests: number | null;
total_input_tokens: number | null;
total_output_tokens: number | null;
total_cache_read_tokens: number | null;
total_cache_write_tokens: number | null;
total_premium_requests: number | null;
total_cost: number | null;
total_cached_prompt_cost: number | null;
total_no_cache_input_cost: number | null;
avg_duration: number | null;
avg_ttft: number | null;
avg_tokens_per_second: number | null;
first_timestamp: number | null;
last_timestamp: number | null;
}
interface ModelStatsRow extends AggregatedStatsRow {
model: string;
provider: string;
}
interface FolderStatsRow extends AggregatedStatsRow {
folder: string;
}
let db: Database | null = null;
const BACKFILL_COMPLETE = "complete";
@@ -112,6 +148,7 @@ export async function initDb(): Promise<Database> {
cost_cache_read REAL NOT NULL,
cost_cache_write REAL NOT NULL,
cost_total REAL NOT NULL,
cost_no_cache_input REAL,
agent_type TEXT NOT NULL DEFAULT 'main',
UNIQUE(session_file, entry_id)
);
@@ -183,6 +220,9 @@ export async function initDb(): Promise<Database> {
if (!messageColumns.some(column => column.name === "premium_requests")) {
db.run("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
}
if (!messageColumns.some(column => column.name === "cost_no_cache_input")) {
db.run("ALTER TABLE messages ADD COLUMN cost_no_cache_input REAL");
}
db.run("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
// Token-usage-by-agent: each message is classified main / subagent / advisor
// from its transcript path. A brand-new table gets the column from CREATE
@@ -265,6 +305,7 @@ export async function initDb(): Promise<Database> {
backfillPriorityPremiumRequests(db);
backfillAgentType(db);
backfillMissingCatalogCosts(db);
backfillNoCacheInputCosts(db);
backfillForkDuplicates(db);
return db;
}
@@ -323,6 +364,18 @@ function resolveStoredCost(stats: MessageStats): UsageCost {
return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? storedCost ?? ZERO_USAGE_COST;
}
function calculateNoCacheInputCost(provider: string, modelId: string, tokens: CostTokens): number | null {
const cost = getCatalogCost(provider, modelId);
if (!cost) return null;
const promptInputTokens =
tokens.input +
tokens.cacheRead +
tokens.cacheWrite +
(tokens.orchestration?.input ?? 0) +
(tokens.orchestration?.cacheRead ?? 0);
return calculateUncachedInputCost(cost, promptInputTokens);
}
function backfillMissingCatalogCosts(database: Database): void {
const rows = database
.prepare(`
@@ -358,6 +411,31 @@ function backfillMissingCatalogCosts(database: Database): void {
applyBackfill();
}
function backfillNoCacheInputCosts(database: Database): void {
const rows = database
.prepare(`
SELECT id, provider, model, input_tokens, cache_read_tokens, cache_write_tokens
FROM messages
WHERE cost_no_cache_input IS NULL
`)
.all() as NoCacheInputCostBackfillRow[];
if (rows.length === 0) return;
const update = database.prepare("UPDATE messages SET cost_no_cache_input = ? WHERE id = ?");
const applyBackfill = database.transaction(() => {
for (const row of rows) {
const cost = calculateNoCacheInputCost(row.provider, row.model, {
input: row.input_tokens,
output: 0,
cacheRead: row.cache_read_tokens,
cacheWrite: row.cache_write_tokens,
});
update.run(cost ?? 0, row.id);
}
});
applyBackfill();
}
/**
* Get the stored offset for a session file.
*/
@@ -406,9 +484,9 @@ export function insertMessageStats(stats: MessageStats[]): number {
session_file, entry_id, folder, model, provider, api, timestamp,
duration, ttft, stop_reason, error_message,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, agent_type
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total, cost_no_cache_input, agent_type
)
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM messages
WHERE entry_id = ? AND timestamp = ? AND session_file <> ?
@@ -422,6 +500,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
const insert = db.transaction(() => {
for (const s of stats) {
const cost = resolveStoredCost(s);
const noCacheInputCost = calculateNoCacheInputCost(s.provider, s.model, s.usage) ?? 0;
const result = stmt.run(
s.sessionFile,
s.entryId,
@@ -445,6 +524,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
cost.cacheRead,
cost.cacheWrite,
cost.total,
noCacheInputCost,
s.agentType,
// `WHERE NOT EXISTS` binds: skip when a different session_file
// already holds this (entry_id, timestamp).
@@ -463,7 +543,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
/**
* Build aggregated stats from query results.
*/
function buildAggregatedStats(rows: any[]): AggregatedStats {
function buildAggregatedStats(rows: AggregatedStatsRow[]): AggregatedStats {
if (rows.length === 0) {
return {
totalRequests: 0,
@@ -475,6 +555,7 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
cacheRate: 0,
cacheSavings: 0,
totalCost: 0,
totalPremiumRequests: 0,
avgDuration: null,
@@ -492,6 +573,8 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
const totalInputTokens = row.total_input_tokens || 0;
const totalCacheReadTokens = row.total_cache_read_tokens || 0;
const totalPremiumRequests = row.total_premium_requests || 0;
const noCacheInputCost = row.total_no_cache_input_cost || 0;
const cachedPromptCost = row.total_cached_prompt_cost || 0;
return {
totalRequests,
@@ -506,6 +589,7 @@ function buildAggregatedStats(rows: any[]): AggregatedStats {
totalInputTokens + totalCacheReadTokens > 0
? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
: 0,
cacheSavings: noCacheInputCost > 0 ? (noCacheInputCost - cachedPromptCost) / noCacheInputCost : 0,
totalCost: row.total_cost || 0,
totalPremiumRequests,
avgDuration: row.avg_duration,
@@ -533,6 +617,10 @@ export function getOverallStats(cutoff?: number): AggregatedStats {
SUM(cache_write_tokens) as total_cache_write_tokens,
SUM(premium_requests) as total_premium_requests,
SUM(cost_total) as total_cost,
SUM(CASE WHEN cost_no_cache_input > 0
THEN cost_input + cost_cache_read + cost_cache_write
ELSE 0 END) as total_cached_prompt_cost,
SUM(cost_no_cache_input) as total_no_cache_input_cost,
AVG(duration) as avg_duration,
AVG(ttft) as avg_ttft,
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
@@ -543,7 +631,7 @@ export function getOverallStats(cutoff?: number): AggregatedStats {
`);
const rows = hasCutoff ? stmt.all(cutoff) : stmt.all();
return buildAggregatedStats(rows as any[]);
return buildAggregatedStats(rows as AggregatedStatsRow[]);
}
/**
* Get stats grouped by model.
@@ -564,6 +652,10 @@ export function getStatsByModel(cutoff?: number): ModelStats[] {
SUM(cache_write_tokens) as total_cache_write_tokens,
SUM(premium_requests) as total_premium_requests,
SUM(cost_total) as total_cost,
SUM(CASE WHEN cost_no_cache_input > 0
THEN cost_input + cost_cache_read + cost_cache_write
ELSE 0 END) as total_cached_prompt_cost,
SUM(cost_no_cache_input) as total_no_cache_input_cost,
AVG(duration) as avg_duration,
AVG(ttft) as avg_ttft,
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
@@ -575,7 +667,7 @@ export function getStatsByModel(cutoff?: number): ModelStats[] {
ORDER BY total_requests DESC
`);
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as ModelStatsRow[];
return rows.map(row => ({
model: row.model,
provider: row.provider,
@@ -601,6 +693,10 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
SUM(cache_write_tokens) as total_cache_write_tokens,
SUM(premium_requests) as total_premium_requests,
SUM(cost_total) as total_cost,
SUM(CASE WHEN cost_no_cache_input > 0
THEN cost_input + cost_cache_read + cost_cache_write
ELSE 0 END) as total_cached_prompt_cost,
SUM(cost_no_cache_input) as total_no_cache_input_cost,
AVG(duration) as avg_duration,
AVG(ttft) as avg_ttft,
AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
@@ -612,7 +708,7 @@ export function getStatsByFolder(cutoff?: number): FolderStats[] {
ORDER BY total_requests DESC
`);
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as any[];
const rows = (hasCutoff ? stmt.all(cutoff) : stmt.all()) as FolderStatsRow[];
return rows.map(row => ({
folder: row.folder,
...buildAggregatedStats([row]),
+2 -1
View File
@@ -68,6 +68,7 @@ async function printStats(): Promise<void> {
console.log(` Input Tokens: ${formatNumber(overall.totalInputTokens)}`);
console.log(` Output Tokens: ${formatNumber(overall.totalOutputTokens)}`);
console.log(` Cache Rate: ${formatPercent(overall.cacheRate)}`);
console.log(` Cache Savings: ${formatPercent(overall.cacheSavings)}`);
console.log(` Total Cost: ${formatCost(overall.totalCost)}`);
console.log(` Premium Requests: ${formatNumber(normalizePremiumRequests(overall.totalPremiumRequests ?? 0))}`);
console.log(` Avg Duration: ${overall.avgDuration !== null ? formatDuration(overall.avgDuration) : "-"}`);
@@ -80,7 +81,7 @@ async function printStats(): Promise<void> {
console.log("\nBy Model:");
for (const m of byModel.slice(0, 10)) {
console.log(
` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache`,
` ${m.model}: ${formatNumber(m.totalRequests)} reqs, ${formatCost(m.totalCost)}, ${formatPercent(m.cacheRate)} cache rate, ${formatPercent(m.cacheSavings)} cache savings`,
);
}
}
+6 -1
View File
@@ -25,8 +25,13 @@ export interface AggregatedStats {
totalCacheReadTokens: number;
/** Total cache write tokens */
totalCacheWriteTokens: number;
/** Cache hit rate (0-1) */
/** Percentage of prompt input tokens served from cache (0-1). */
cacheRate: number;
/**
* Prompt-input cost saved relative to billing the same tokens uncached
* (0-1; negative when cache writes cost more than reads save).
*/
cacheSavings: number;
/** Total cost */
totalCost: number;
/** Total premium requests */
+79 -1
View File
@@ -1,6 +1,6 @@
import { Database } from "bun:sqlite";
import { describe, expect, it } from "bun:test";
import { closeDb, getRecentRequests, initDb, insertMessageStats } from "@oh-my-pi/omp-stats/db";
import { closeDb, getOverallStats, getRecentRequests, initDb, insertMessageStats } from "@oh-my-pi/omp-stats/db";
import type { MessageStats } from "@oh-my-pi/omp-stats/types";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { getStatsDbPath } from "@oh-my-pi/pi-utils";
@@ -46,6 +46,32 @@ function expectedCodexGptCost() {
};
}
function createAnthropicCacheStats(entryId: string, cacheRead: number, cacheWrite: number): MessageStats {
const input = 1_000 - cacheRead - cacheWrite;
return {
sessionFile: "/tmp/anthropic-session.jsonl",
entryId,
folder: "/tmp/project",
model: "claude-sonnet-4-6",
provider: "anthropic",
api: "anthropic-messages",
timestamp: Date.now(),
duration: 1000,
ttft: 100,
stopReason: "stop",
errorMessage: null,
usage: {
input,
output: 0,
cacheRead,
cacheWrite,
totalTokens: 1_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
agentType: "main",
};
}
describe("stats GPT cost correction", () => {
it("stores catalog-derived cost when OpenAI Codex session usage has zero cost", async () => {
await initDb();
@@ -107,3 +133,55 @@ describe("stats GPT cost correction", () => {
expect(request?.usage.cost.total).toBeCloseTo(expectedCodexGptCost().total, 8);
});
});
describe("stats cache metrics", () => {
it("subtracts 5-minute writes from the savings produced by cache reads", async () => {
await initDb();
insertMessageStats([createAnthropicCacheStats("mixed-cache", 800, 100)]);
// 100 uncached + 800 reads at 0.1x + 100 writes at 1.25x = 305,
// versus 1,000 tokens at the uncached input rate.
expect(getOverallStats().cacheSavings).toBeCloseTo(0.695, 8);
expect(getOverallStats().cacheRate).toBeCloseTo(800 / 900, 8);
});
it("reports cache writes without reads as negative savings", async () => {
await initDb();
insertMessageStats([createAnthropicCacheStats("cache-write", 0, 1_000)]);
expect(getOverallStats().cacheSavings).toBeCloseTo(-0.25, 8);
});
it("charges 1-hour cache writes at their full overhead", async () => {
await initDb();
const stats = createAnthropicCacheStats("one-hour-write", 0, 1_000);
stats.usage.cost = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0.006,
total: 0.006,
};
insertMessageStats([stats]);
expect(getOverallStats().cacheSavings).toBeCloseTo(-1, 8);
});
it("excludes unpriced custom models from the savings ratio", async () => {
await initDb();
const known = createAnthropicCacheStats("known", 800, 100);
const unpriced = createAnthropicCacheStats("unpriced", 0, 0);
unpriced.provider = "custom";
unpriced.model = "custom-model";
unpriced.usage.cost = {
input: 1,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 1,
};
insertMessageStats([known, unpriced]);
expect(getOverallStats().cacheSavings).toBeCloseTo(0.695, 8);
});
});
@@ -14,6 +14,7 @@ const stats: AggregatedStats = {
totalCacheReadTokens: 300,
totalCacheWriteTokens: 40,
cacheRate: 0.75,
cacheSavings: 0.695,
totalCost: 0,
totalPremiumRequests: 0,
avgDuration: 1000,
@@ -31,6 +32,11 @@ describe("overview token metrics", () => {
expect(html).toContain("Cache Read");
expect(html).toContain("Conversation Total");
expect(html).toContain("Uncached input + cache reads + cache writes + output");
expect(html).toContain("Cache Rate");
expect(html).toContain("Cache Savings");
expect(html).toContain("75.0%");
expect(html).toContain("69.5%");
expect(html).toContain("cache writes can make this negative");
const expectedTotal = formatCompact(
stats.totalInputTokens +
+13 -11
View File
@@ -25,13 +25,16 @@
* (packages/natives/scripts/build-bindings.ts) against the installed VS Build
* Tools; every other target on a win32 host fails fast with guidance.
*
* Set `OMP_NATIVE_BUILD_BACKEND=cargo` to route the host target through the
* same local N-API build on systems where Bazel's prebuilt host tools cannot run.
*
* Note: musl addons intentionally reuse the plain linux-<arch> filenames, so a
* `linux-all` copy overwrites the gnu addon with the musl one (and vice versa);
* CI jobs that ship files always request an explicit disjoint target set.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { detectHostAvx2Support } from "./host-detect";
import { detectHostAvx2Support, resolveLocalHostAddon } from "./host-detect";
const repoRoot = path.join(import.meta.dir, "..");
@@ -221,14 +224,10 @@ async function installAddon(sourcePath: string, destPath: string): Promise<void>
}
}
/**
* win32-host path for the `host` pseudo-target: the bazel msvc cross toolchain
* cannot run here, but real MSVC can — build the addon via the napi local
* build and install it into destDir like the bazel path would.
*/
async function buildWindowsHostAddon(host: HostInfo, destDir: string): Promise<void> {
/** Build and install the host addon through the local Cargo/N-API path. */
async function buildLocalHostAddon(host: HostInfo, destDir: string): Promise<void> {
const script = path.join(repoRoot, "packages/natives/scripts/build-bindings.ts");
console.log(`win32 host: bazel msvc toolchain is linux/mac-only; building via ${path.relative(repoRoot, script)}`);
console.log(`local host build: using ${path.relative(repoRoot, script)}`);
const proc = Bun.spawn([process.execPath, script], {
cwd: repoRoot,
stdout: "inherit",
@@ -237,7 +236,7 @@ async function buildWindowsHostAddon(host: HostInfo, destDir: string): Promise<v
const exitCode = await proc.exited;
if (exitCode !== 0) process.exit(exitCode || 1);
const filename = `pi_natives.win32-x64-${host.avx2 ? "modern" : "baseline"}.node`;
const filename = resolveLocalHostAddon(host).filename;
const builtPath = path.join(repoRoot, "packages/natives/native", filename);
if (path.dirname(builtPath) !== destDir) {
await fs.mkdir(destDir, { recursive: true });
@@ -251,15 +250,18 @@ async function main(): Promise<void> {
const host: HostInfo = { platform: process.platform, arch: process.arch, avx2: detectHostAvx2Support() };
const destDir = options.dest ? path.resolve(options.dest) : path.join(repoRoot, "packages/natives/native");
if (host.platform === "win32" && !options.source) {
if ((host.platform === "win32" || Bun.env.OMP_NATIVE_BUILD_BACKEND === "cargo") && !options.source) {
if (options.targets.length !== 1 || options.targets[0] !== "host") {
if (host.platform === "win32") {
throw new Error(
`Cannot bazel-build [${options.targets.join(", ")}] on a Windows host: the msvc cross ` +
"toolchain (bazel/toolchains/msvc) only runs on linux/mac exec hosts. Use `host` here " +
"(local napi build via VS Build Tools), or run this script from WSL/linux for cross targets.",
);
}
await buildWindowsHostAddon(host, destDir);
throw new Error("OMP_NATIVE_BUILD_BACKEND=cargo supports only the host target");
}
await buildLocalHostAddon(host, destDir);
return;
}
let outputs: string[];
+20
View File
@@ -10,7 +10,27 @@ function runCommand(command: string, args: string[]): string | null {
return null;
}
}
/** Local N-API addon identity derived from a host platform and ISA. */
export interface LocalHostAddon {
readonly filename: string;
readonly x64Variant: "modern" | "baseline" | null;
}
/** Resolve the exact filename and x86-64 ISA emitted by the local N-API build. */
export function resolveLocalHostAddon(host: {
readonly platform: string;
readonly arch: string;
readonly avx2: boolean;
}): LocalHostAddon {
const x64Variant = host.arch === "x64" ? (host.avx2 ? "modern" : "baseline") : null;
const variantSuffix = x64Variant ? `-${x64Variant}` : "";
return {
filename: `pi_natives.${host.platform}-${host.arch}${variantSuffix}.node`,
x64Variant,
};
}
/** Detect whether this x86-64 host can run the modern AVX2 addon. */
export function detectHostAvx2Support(): boolean {
if (process.arch !== "x64") return false;