mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
fix(pi): report authoritative context usage (#1106)
This commit is contained in:
+163
-6
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { parsePiModels, parsePiCommands, sendPiRpcAndWait, wireTransportEvents } from './loop';
|
import { parsePiModels, parsePiCommands, parsePiContextUsage, sendPiRpcAndWait, wireTransportEvents } from './loop';
|
||||||
import type { PiResponseEvent } from './types';
|
import type { PiResponseEvent } from './types';
|
||||||
import { PiSession } from './session';
|
import { PiSession } from './session';
|
||||||
import { PiTransport } from './piTransport';
|
import { PiTransport } from './piTransport';
|
||||||
@@ -19,9 +19,13 @@ vi.mock('@/agent/messageConverter', () => ({
|
|||||||
convertAgentMessage: vi.fn((msg) => msg),
|
convertAgentMessage: vi.fn((msg) => msg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./PiEventConverter', () => ({
|
vi.mock('./piEventConverter', async (importOriginal) => {
|
||||||
convertPiEvent: vi.fn(() => []),
|
const actual = await importOriginal<typeof import('./piEventConverter')>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
convertPiEvent: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./piMessageAccumulator', () => {
|
vi.mock('./piMessageAccumulator', () => {
|
||||||
return {
|
return {
|
||||||
@@ -191,6 +195,26 @@ describe('parsePiCommands', () => {
|
|||||||
expect(parsePiCommands(data)).toEqual([{ name: 'cmd', source: 'skill' }]);
|
expect(parsePiCommands(data)).toEqual([{ name: 'cmd', source: 'skill' }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// --- parsePiContextUsage ---
|
||||||
|
|
||||||
|
describe('parsePiContextUsage', () => {
|
||||||
|
it('parses Pi authoritative context usage', () => {
|
||||||
|
expect(parsePiContextUsage({
|
||||||
|
contextUsage: { tokens: 101_035, contextWindow: 200_000, percent: 50.5 },
|
||||||
|
})).toEqual({ tokens: 101_035, contextWindow: 200_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves Pi explicit unknown context after compaction', () => {
|
||||||
|
expect(parsePiContextUsage({
|
||||||
|
contextUsage: { tokens: null, contextWindow: 200_000 },
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unavailable for missing or malformed tokens', () => {
|
||||||
|
expect(parsePiContextUsage({})).toBeUndefined();
|
||||||
|
expect(parsePiContextUsage({ contextUsage: { tokens: '101035' } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- wireTransportEvents (integration) ---
|
// --- wireTransportEvents (integration) ---
|
||||||
|
|
||||||
@@ -217,6 +241,10 @@ describe('wireTransportEvents', () => {
|
|||||||
handler!(event);
|
handler!(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSentCommand(transport: PiTransport, index = 0): Record<string, unknown> {
|
||||||
|
return (transport.send as ReturnType<typeof vi.fn>).mock.calls[index][0] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
it('handles get_state response — updates model, provider, thinkingLevel', () => {
|
it('handles get_state response — updates model, provider, thinkingLevel', () => {
|
||||||
const transport = createMockTransport();
|
const transport = createMockTransport();
|
||||||
const pendingLocalIds: string[] = [];
|
const pendingLocalIds: string[] = [];
|
||||||
@@ -331,14 +359,143 @@ describe('wireTransportEvents', () => {
|
|||||||
expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['prompt-1'], undefined);
|
expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['prompt-1'], undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles turn_end — stops streaming', () => {
|
it('publishes authoritative context usage after turn_end stats resolve', async () => {
|
||||||
const transport = createMockTransport();
|
const transport = createMockTransport();
|
||||||
wireTransportEvents(transport, session, []);
|
wireTransportEvents(transport, session, []);
|
||||||
|
|
||||||
session.piIsStreaming = true;
|
session.piIsStreaming = true;
|
||||||
emitEvent({ type: 'turn_end' });
|
emitEvent({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 100, output: 200, cacheRead: 10, cacheWrite: 5, totalTokens: 315 },
|
||||||
|
stopReason: 'stop',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(session.piIsStreaming).toBe(false);
|
expect(session.piIsStreaming).toBe(false);
|
||||||
|
expect(session.client.sendAgentMessage).not.toHaveBeenCalled();
|
||||||
|
const command = getSentCommand(transport);
|
||||||
|
expect(command).toMatchObject({ type: 'get_session_stats' });
|
||||||
|
|
||||||
|
emitEvent({
|
||||||
|
type: 'response',
|
||||||
|
id: command.id,
|
||||||
|
command: 'get_session_stats',
|
||||||
|
success: true,
|
||||||
|
data: { contextUsage: { tokens: 342, contextWindow: 200_000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(session.client.sendAgentMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'usage',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 200,
|
||||||
|
totalTokens: 315,
|
||||||
|
cacheReadTokens: 10,
|
||||||
|
contextTokens: 342,
|
||||||
|
contextWindow: 200_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently falls back to turn totalTokens when stats are unsupported', async () => {
|
||||||
|
const transport = createMockTransport();
|
||||||
|
wireTransportEvents(transport, session, []);
|
||||||
|
|
||||||
|
emitEvent({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 100, output: 200, cacheRead: 10, cacheWrite: 5, totalTokens: 315 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const command = getSentCommand(transport);
|
||||||
|
|
||||||
|
emitEvent({
|
||||||
|
type: 'response',
|
||||||
|
id: command.id,
|
||||||
|
command: 'get_session_stats',
|
||||||
|
success: false,
|
||||||
|
error: 'Unknown command',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(session.client.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'usage',
|
||||||
|
contextTokens: 315,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
expect(session.client.sendSessionEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to turn totalTokens when stats time out', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const transport = createMockTransport();
|
||||||
|
wireTransportEvents(transport, session, []);
|
||||||
|
|
||||||
|
emitEvent({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 100, output: 200, cacheRead: 10, cacheWrite: 5, totalTokens: 315 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.client.sendAgentMessage).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
|
||||||
|
expect(session.client.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'usage',
|
||||||
|
contextTokens: 315,
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards a stats response from an older completed turn', async () => {
|
||||||
|
const transport = createMockTransport();
|
||||||
|
wireTransportEvents(transport, session, []);
|
||||||
|
|
||||||
|
emitEvent({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 10, output: 20, cacheRead: 0, cacheWrite: 0, totalTokens: 30 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emitEvent({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 40, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 90 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const olderCommand = getSentCommand(transport, 0);
|
||||||
|
const latestCommand = getSentCommand(transport, 1);
|
||||||
|
emitEvent({
|
||||||
|
type: 'response',
|
||||||
|
id: latestCommand.id,
|
||||||
|
command: 'get_session_stats',
|
||||||
|
success: true,
|
||||||
|
data: { contextUsage: { tokens: 120, contextWindow: 200_000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(session.client.sendAgentMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(session.client.sendAgentMessage).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
contextTokens: 120,
|
||||||
|
}));
|
||||||
|
|
||||||
|
emitEvent({
|
||||||
|
type: 'response',
|
||||||
|
id: olderCommand.id,
|
||||||
|
command: 'get_session_stats',
|
||||||
|
success: true,
|
||||||
|
data: { contextUsage: { tokens: 45, contextWindow: 200_000 } },
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(session.client.sendAgentMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles agent_end — stops streaming', () => {
|
it('handles agent_end — stops streaming', () => {
|
||||||
|
|||||||
+55
-5
@@ -1,14 +1,14 @@
|
|||||||
import { logger } from '@/ui/logger';
|
import { logger } from '@/ui/logger';
|
||||||
import { convertAgentMessage } from '@/agent/messageConverter';
|
import { convertAgentMessage } from '@/agent/messageConverter';
|
||||||
import { PiTransport } from './piTransport';
|
import { PiTransport } from './piTransport';
|
||||||
import { convertPiEvent } from './piEventConverter';
|
import { convertPiEvent, convertPiTurnUsage } from './piEventConverter';
|
||||||
import { PiMessageAccumulator } from './piMessageAccumulator';
|
import { PiMessageAccumulator } from './piMessageAccumulator';
|
||||||
import { parsePiModels, parsePiCommands, PiResponseEventSchema, PiStateDataSchema, PiSetModelDataSchema } from './schemas';
|
import { parsePiModels, parsePiCommands, parsePiContextUsage, PiResponseEventSchema, PiStateDataSchema, PiSetModelDataSchema } from './schemas';
|
||||||
import type { PiResponseEvent, PiRpcCommand, PiThinkingLevel } from './types';
|
import type { PiContextUsage, PiResponseEvent, PiRpcCommand, PiThinkingLevel, PiTurnEndEvent } from './types';
|
||||||
import type { PiSession } from './session';
|
import type { PiSession } from './session';
|
||||||
|
|
||||||
// --- Response parsers: re-exported from schemas.ts ---
|
// --- Response parsers: re-exported from schemas.ts ---
|
||||||
export { parsePiModels, parsePiCommands } from './schemas';
|
export { parsePiModels, parsePiCommands, parsePiContextUsage } from './schemas';
|
||||||
|
|
||||||
// --- Pending RPC resolver ---
|
// --- Pending RPC resolver ---
|
||||||
// Instance-scoped: created once by wireTransportEvents, stored on PiSession.
|
// Instance-scoped: created once by wireTransportEvents, stored on PiSession.
|
||||||
@@ -143,7 +143,12 @@ function handleResponse(
|
|||||||
const error = response.error ?? 'Unknown Pi error';
|
const error = response.error ?? 'Unknown Pi error';
|
||||||
logger.debug(`[pi] RPC error for ${command}: ${error}`);
|
logger.debug(`[pi] RPC error for ${command}: ${error}`);
|
||||||
resolvePendingRpc(resolver, response);
|
resolvePendingRpc(resolver, response);
|
||||||
session.sendSessionEvent({ type: 'message', message: error });
|
// get_session_stats is a best-effort compatibility probe. Older Pi
|
||||||
|
// versions may reject it, so fall back silently instead of surfacing an
|
||||||
|
// error event to the user on every completed turn.
|
||||||
|
if (command !== 'get_session_stats') {
|
||||||
|
session.sendSessionEvent({ type: 'message', message: error });
|
||||||
|
}
|
||||||
if (command === 'prompt' && pendingLocalIds.length > 0) {
|
if (command === 'prompt' && pendingLocalIds.length > 0) {
|
||||||
const oldestLocalId = pendingLocalIds.shift()!;
|
const oldestLocalId = pendingLocalIds.shift()!;
|
||||||
session.emitMessagesConsumed([oldestLocalId], { clearQueuedThinkingGrace: true });
|
session.emitMessagesConsumed([oldestLocalId], { clearQueuedThinkingGrace: true });
|
||||||
@@ -257,6 +262,43 @@ function handleResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PI_CONTEXT_USAGE_RPC_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
|
async function publishPiTurnUsage(
|
||||||
|
event: PiTurnEndEvent,
|
||||||
|
transport: PiTransport,
|
||||||
|
session: PiSession,
|
||||||
|
isLatestRequest: () => boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
let contextUsage: PiContextUsage | null | undefined;
|
||||||
|
try {
|
||||||
|
const stats = await sendPiRpcAndWait(
|
||||||
|
session,
|
||||||
|
transport,
|
||||||
|
{ type: 'get_session_stats' },
|
||||||
|
PI_CONTEXT_USAGE_RPC_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
contextUsage = parsePiContextUsage(stats);
|
||||||
|
} catch (error) {
|
||||||
|
// Unsupported/failed stats RPC: convertPiTurnUsage falls back to the
|
||||||
|
// positive per-turn totalTokens value. The fallback is intentionally
|
||||||
|
// local to Pi so providers with different usage semantics are untouched.
|
||||||
|
logger.debug(`[pi] get_session_stats unavailable, using turn usage fallback: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
contextUsage = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPC responses can arrive after a newer turn has already completed.
|
||||||
|
// Publishing only the newest request prevents stale context values from
|
||||||
|
// overwriting a later turn's usage state.
|
||||||
|
if (!isLatestRequest()) return;
|
||||||
|
|
||||||
|
const usageMessage = convertPiTurnUsage(event, contextUsage);
|
||||||
|
if (!usageMessage) return;
|
||||||
|
|
||||||
|
const converted = convertAgentMessage(usageMessage);
|
||||||
|
if (converted) session.sendAgentMessage(converted);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Wire transport events to session ---
|
// --- Wire transport events to session ---
|
||||||
|
|
||||||
export function wireTransportEvents(
|
export function wireTransportEvents(
|
||||||
@@ -266,6 +308,7 @@ export function wireTransportEvents(
|
|||||||
): void {
|
): void {
|
||||||
session.rpcResolver = new PiRpcResolver();
|
session.rpcResolver = new PiRpcResolver();
|
||||||
const assistantMessageAccumulator = new PiMessageAccumulator();
|
const assistantMessageAccumulator = new PiMessageAccumulator();
|
||||||
|
let latestContextUsageRequest = 0;
|
||||||
|
|
||||||
transport.onEvent((event) => {
|
transport.onEvent((event) => {
|
||||||
// Debug: log all event types to diagnose missing Pi output
|
// Debug: log all event types to diagnose missing Pi output
|
||||||
@@ -312,6 +355,13 @@ export function wireTransportEvents(
|
|||||||
}
|
}
|
||||||
} else if (event.type === 'turn_end') {
|
} else if (event.type === 'turn_end') {
|
||||||
session.updateThinkingState(false);
|
session.updateThinkingState(false);
|
||||||
|
const requestVersion = ++latestContextUsageRequest;
|
||||||
|
void publishPiTurnUsage(
|
||||||
|
event as PiTurnEndEvent,
|
||||||
|
transport,
|
||||||
|
session,
|
||||||
|
() => requestVersion === latestContextUsageRequest,
|
||||||
|
);
|
||||||
} else if (event.type === 'agent_end') {
|
} else if (event.type === 'agent_end') {
|
||||||
session.piIsStreaming = false;
|
session.piIsStreaming = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { convertPiEvent } from './piEventConverter';
|
import { convertPiEvent, convertPiTurnUsage } from './piEventConverter';
|
||||||
import type { PiAgentEvent } from './types';
|
import type { PiAgentEvent } from './types';
|
||||||
|
|
||||||
describe('convertPiEvent', () => {
|
describe('convertPiEvent', () => {
|
||||||
@@ -132,7 +132,7 @@ describe('convertPiEvent', () => {
|
|||||||
expect((result[0] as any).id).toBeUndefined();
|
expect((result[0] as any).id).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should convert turn_end to usage + turn_complete (2 messages)', () => {
|
it('should defer turn usage and convert only turn completion', () => {
|
||||||
const result = convertPiEvent({
|
const result = convertPiEvent({
|
||||||
type: 'turn_end',
|
type: 'turn_end',
|
||||||
message: {
|
message: {
|
||||||
@@ -148,20 +148,68 @@ describe('convertPiEvent', () => {
|
|||||||
toolResults: []
|
toolResults: []
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toEqual([{
|
||||||
expect(result[0]).toEqual({
|
type: 'turn_complete',
|
||||||
|
stopReason: 'stop'
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build usage from Pi authoritative context stats', () => {
|
||||||
|
const result = convertPiTurnUsage({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 100, output: 200, cacheRead: 10, cacheWrite: 5, totalTokens: 315 }
|
||||||
|
}
|
||||||
|
}, { tokens: 342, contextWindow: 200_000 });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
type: 'usage',
|
type: 'usage',
|
||||||
inputTokens: 100,
|
inputTokens: 100,
|
||||||
outputTokens: 200,
|
outputTokens: 200,
|
||||||
totalTokens: 315,
|
totalTokens: 315,
|
||||||
cacheReadTokens: 10
|
cacheReadTokens: 10,
|
||||||
|
contextTokens: 342,
|
||||||
|
contextWindow: 200_000
|
||||||
});
|
});
|
||||||
expect(result[1]).toEqual({
|
});
|
||||||
type: 'turn_complete',
|
|
||||||
stopReason: 'stop'
|
it('should fall back to positive totalTokens when stats are unavailable', () => {
|
||||||
|
const result = convertPiTurnUsage({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 100, output: 200, cacheRead: 10, cacheWrite: 5, totalTokens: 315 }
|
||||||
|
}
|
||||||
|
}, undefined);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
type: 'usage',
|
||||||
|
totalTokens: 315,
|
||||||
|
contextTokens: 315
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should preserve prior usage when Pi explicitly reports unknown context', () => {
|
||||||
|
const result = convertPiTurnUsage({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 100, output: 200, cacheRead: 10, cacheWrite: 5, totalTokens: 315 }
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip all-zero error or aborted usage', () => {
|
||||||
|
const result = convertPiTurnUsage({
|
||||||
|
type: 'turn_end',
|
||||||
|
message: {
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }
|
||||||
|
}
|
||||||
|
}, { tokens: 342, contextWindow: 200_000 });
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('should convert turn_end with toolUse stopReason', () => {
|
it('should convert turn_end with toolUse stopReason', () => {
|
||||||
const result = convertPiEvent({
|
const result = convertPiEvent({
|
||||||
type: 'turn_end',
|
type: 'turn_end',
|
||||||
@@ -172,8 +220,8 @@ describe('convertPiEvent', () => {
|
|||||||
toolResults: []
|
toolResults: []
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[1]).toEqual({
|
expect(result[0]).toEqual({
|
||||||
type: 'turn_complete',
|
type: 'turn_complete',
|
||||||
stopReason: 'toolUse'
|
stopReason: 'toolUse'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,42 @@ import type {
|
|||||||
PiAgentEvent,
|
PiAgentEvent,
|
||||||
PiToolExecutionStartEvent,
|
PiToolExecutionStartEvent,
|
||||||
PiToolExecutionEndEvent,
|
PiToolExecutionEndEvent,
|
||||||
PiTurnEndEvent
|
PiTurnEndEvent,
|
||||||
|
PiContextUsage,
|
||||||
|
PiUsage
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
function hasMeaningfulUsage(usage: PiUsage | undefined): usage is PiUsage {
|
||||||
|
return usage !== undefined
|
||||||
|
&& Number.isFinite(usage.totalTokens)
|
||||||
|
&& usage.totalTokens > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the turn usage update after Pi's session stats request settles.
|
||||||
|
*
|
||||||
|
* undefined stats fall back to the turn's totalTokens for older Pi versions.
|
||||||
|
* null means Pi explicitly reported an unknown context size, so the previous
|
||||||
|
* valid HAPI usage state is preserved by not publishing an update.
|
||||||
|
*/
|
||||||
|
export function convertPiTurnUsage(
|
||||||
|
event: PiTurnEndEvent,
|
||||||
|
contextUsage: PiContextUsage | null | undefined,
|
||||||
|
): AgentMessage | null {
|
||||||
|
const usage = event.message?.usage;
|
||||||
|
if (!hasMeaningfulUsage(usage) || contextUsage === null) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'usage',
|
||||||
|
inputTokens: usage.input ?? 0,
|
||||||
|
outputTokens: usage.output ?? 0,
|
||||||
|
totalTokens: usage.totalTokens,
|
||||||
|
cacheReadTokens: usage.cacheRead,
|
||||||
|
contextTokens: contextUsage?.tokens ?? usage.totalTokens,
|
||||||
|
contextWindow: contextUsage?.contextWindow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts Pi AgentEvent to HAPI AgentMessage array.
|
* Converts Pi AgentEvent to HAPI AgentMessage array.
|
||||||
*
|
*
|
||||||
@@ -40,25 +73,10 @@ export function convertPiEvent(event: PiAgentEvent): AgentMessage[] {
|
|||||||
|
|
||||||
case 'turn_end': {
|
case 'turn_end': {
|
||||||
const e = event as PiTurnEndEvent;
|
const e = event as PiTurnEndEvent;
|
||||||
const messages: AgentMessage[] = [];
|
return [{
|
||||||
const usage = e.message?.usage;
|
|
||||||
|
|
||||||
if (usage) {
|
|
||||||
messages.push({
|
|
||||||
type: 'usage',
|
|
||||||
inputTokens: usage.input ?? 0,
|
|
||||||
outputTokens: usage.output ?? 0,
|
|
||||||
totalTokens: usage.totalTokens,
|
|
||||||
cacheReadTokens: usage.cacheRead
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({
|
|
||||||
type: 'turn_complete',
|
type: 'turn_complete',
|
||||||
stopReason: e.message?.stopReason ?? 'stop'
|
stopReason: e.message?.stopReason ?? 'stop'
|
||||||
});
|
}];
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle and other events — not converted to AgentMessage.
|
// Lifecycle and other events — not converted to AgentMessage.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PI_THINKING_LEVELS } from '@hapi/protocol';
|
import { PI_THINKING_LEVELS } from '@hapi/protocol';
|
||||||
import type { PiModelSummary } from '@hapi/protocol/apiTypes';
|
import type { PiModelSummary } from '@hapi/protocol/apiTypes';
|
||||||
|
import type { PiContextUsage } from './types';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 字段级容错 schema
|
// 字段级容错 schema
|
||||||
@@ -23,6 +24,17 @@ const asOptStr = z.unknown().optional().transform(v => typeof v === 'string' ? v
|
|||||||
/** 提取 number 值,非 number 或缺失返回 undefined */
|
/** 提取 number 值,非 number 或缺失返回 undefined */
|
||||||
const asOptNum = z.unknown().optional().transform(v => typeof v === 'number' ? v : undefined);
|
const asOptNum = z.unknown().optional().transform(v => typeof v === 'number' ? v : undefined);
|
||||||
|
|
||||||
|
/** Extract a finite positive number, otherwise return undefined. */
|
||||||
|
const asOptPositiveNum = z.unknown().optional().transform(v =>
|
||||||
|
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Context usage tokens may be null immediately after compaction. */
|
||||||
|
const asContextTokens = z.unknown().optional().transform((v): number | null | undefined => {
|
||||||
|
if (v === null) return null;
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
/** 提取 boolean 值,非 boolean 或缺失返回 undefined */
|
/** 提取 boolean 值,非 boolean 或缺失返回 undefined */
|
||||||
const asOptBool = z.unknown().optional().transform(v => typeof v === 'boolean' ? v : undefined);
|
const asOptBool = z.unknown().optional().transform(v => typeof v === 'boolean' ? v : undefined);
|
||||||
|
|
||||||
@@ -135,6 +147,13 @@ const PiModelsResponseDataSchema = z.object({
|
|||||||
.map(r => r.data),
|
.map(r => r.data),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PiSessionStatsDataSchema = z.object({
|
||||||
|
contextUsage: z.object({
|
||||||
|
tokens: asContextTokens,
|
||||||
|
contextWindow: asOptPositiveNum,
|
||||||
|
}).passthrough().optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Pi State (get_state response data)
|
// Pi State (get_state response data)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -203,3 +222,20 @@ export function parsePiModels(data: unknown) {
|
|||||||
const result = PiModelsResponseDataSchema.safeParse(data)
|
const result = PiModelsResponseDataSchema.safeParse(data)
|
||||||
return result.success ? result.data : []
|
return result.success ? result.data : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Pi's authoritative current context-window estimate.
|
||||||
|
*
|
||||||
|
* undefined: stats unavailable/malformed; callers may fall back to turn usage.
|
||||||
|
* null: Pi explicitly reports unknown (for example, immediately after compaction).
|
||||||
|
*/
|
||||||
|
export function parsePiContextUsage(data: unknown): PiContextUsage | null | undefined {
|
||||||
|
const result = PiSessionStatsDataSchema.safeParse(data);
|
||||||
|
if (!result.success || !result.data.contextUsage) return undefined;
|
||||||
|
|
||||||
|
const { tokens, contextWindow } = result.data.contextUsage;
|
||||||
|
if (tokens === null) return null;
|
||||||
|
if (tokens === undefined) return undefined;
|
||||||
|
|
||||||
|
return contextWindow === undefined ? { tokens } : { tokens, contextWindow };
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -37,6 +37,11 @@ export interface PiUsage {
|
|||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PiContextUsage {
|
||||||
|
tokens: number;
|
||||||
|
contextWindow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Individual event types for proper type narrowing
|
// Individual event types for proper type narrowing
|
||||||
export interface PiAgentStartEvent { type: 'agent_start' }
|
export interface PiAgentStartEvent { type: 'agent_start' }
|
||||||
export interface PiAgentEndEvent { type: 'agent_end'; messages: unknown[] }
|
export interface PiAgentEndEvent { type: 'agent_end'; messages: unknown[] }
|
||||||
@@ -104,7 +109,8 @@ export type PiRpcCommand =
|
|||||||
| { type: 'set_model'; provider: string; modelId: string }
|
| { type: 'set_model'; provider: string; modelId: string }
|
||||||
| { type: 'get_available_models' }
|
| { type: 'get_available_models' }
|
||||||
| { type: 'set_thinking_level'; level: PiThinkingLevel }
|
| { type: 'set_thinking_level'; level: PiThinkingLevel }
|
||||||
| { type: 'get_commands' };
|
| { type: 'get_commands' }
|
||||||
|
| { type: 'get_session_stats' };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Pi RPC Responses (stdout)
|
// Pi RPC Responses (stdout)
|
||||||
|
|||||||
Reference in New Issue
Block a user