diff --git a/package-lock.json b/package-lock.json index a1c6dc39e..2f3118927 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5678,6 +5678,7 @@ "version": "0.83.0", "license": "MIT", "dependencies": { + "@earendil-works/pi-ai": "^0.83.0", "typebox": "1.3.7" }, "devDependencies": { diff --git a/packages/ai/package.json b/packages/ai/package.json index a6773c7b1..7edcfc789 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -15,6 +15,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./schemas": { + "types": "./dist/schemas.d.ts", + "import": "./dist/schemas.js" + }, "./compat": { "types": "./dist/compat.d.ts", "import": "./dist/compat.js" diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4ff678102..0ee766da3 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -34,6 +34,7 @@ export * from "./images-models.ts"; export * from "./models.ts"; export * from "./models-store.ts"; export * from "./providers/faux.ts"; +export * from "./schemas.ts"; export * from "./session-resources.ts"; export * from "./types.ts"; export * from "./utils/diagnostics.ts"; diff --git a/packages/ai/src/schemas.ts b/packages/ai/src/schemas.ts new file mode 100644 index 000000000..416202396 --- /dev/null +++ b/packages/ai/src/schemas.ts @@ -0,0 +1,82 @@ +import Type from "typebox"; + +const StrictObject = [0]>(properties: T) => + Type.Object(properties, { additionalProperties: false }); + +export const ThinkingLevelSchema = Type.Union([ + Type.Literal("minimal"), + Type.Literal("low"), + Type.Literal("medium"), + Type.Literal("high"), + Type.Literal("xhigh"), + Type.Literal("max"), +]); + +export const ModelThinkingLevelSchema = Type.Union([Type.Literal("off"), ThinkingLevelSchema]); + +export const TextContentSchema = StrictObject({ + type: Type.Literal("text"), + text: Type.String(), + /** Provider message metadata, such as an OpenAI response item identifier. */ + textSignature: Type.Optional(Type.String()), +}); + +export const ThinkingContentSchema = StrictObject({ + type: Type.Literal("thinking"), + thinking: Type.String(), + /** Opaque provider reasoning identifier or encrypted payload used for multi-turn continuity. */ + thinkingSignature: Type.Optional(Type.String()), + /** Whether safety filters redacted the thinking content. */ + redacted: Type.Optional(Type.Boolean()), +}); + +export const ImageContentSchema = StrictObject({ + type: Type.Literal("image"), + /** Base64-encoded image data. */ + data: Type.String(), + mimeType: Type.String({ minLength: 1 }), +}); + +export const ToolCallSchema = StrictObject({ + type: Type.Literal("toolCall"), + id: Type.String(), + name: Type.String(), + arguments: Type.Record(Type.String(), Type.Any()), + /** Google-specific opaque signature for reusing thought context. */ + thoughtSignature: Type.Optional(Type.String()), +}); + +export const UsageSchema = StrictObject({ + input: Type.Integer({ minimum: 0 }), + output: Type.Integer({ minimum: 0 }), + cacheRead: Type.Integer({ minimum: 0 }), + cacheWrite: Type.Integer({ minimum: 0 }), + /** Subset of cacheWrite written with one-hour retention. */ + cacheWrite1h: Type.Optional(Type.Integer({ minimum: 0 })), + /** Provider-reported reasoning tokens, already included in output. */ + reasoning: Type.Optional(Type.Integer({ minimum: 0 })), + totalTokens: Type.Integer({ minimum: 0 }), + cost: StrictObject({ + input: Type.Number({ minimum: 0 }), + output: Type.Number({ minimum: 0 }), + cacheRead: Type.Number({ minimum: 0 }), + cacheWrite: Type.Number({ minimum: 0 }), + total: Type.Number({ minimum: 0 }), + }), +}); + +export const StopReasonSchema = Type.Union([ + Type.Literal("pending"), + Type.Literal("stop"), + Type.Literal("length"), + Type.Literal("toolUse"), + Type.Literal("error"), + Type.Literal("aborted"), +]); + +export const ModelCostRatesSchema = StrictObject({ + input: Type.Number({ minimum: 0 }), + output: Type.Number({ minimum: 0 }), + cacheRead: Type.Number({ minimum: 0 }), + cacheWrite: Type.Number({ minimum: 0 }), +}); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 26470a458..c26ffb0c9 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,3 +1,4 @@ +import type { Static, TSchema } from "typebox"; import type { AnthropicOptions } from "./api/anthropic-messages.ts"; import type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts"; import type { BedrockOptions } from "./api/bedrock-converse-stream.ts"; @@ -8,6 +9,17 @@ import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.t import type { OpenAICompletionsOptions } from "./api/openai-completions.ts"; import type { OpenAIResponsesOptions } from "./api/openai-responses.ts"; import type { PiMessagesOptions } from "./api/pi-messages.ts"; +import type { + ImageContentSchema, + ModelCostRatesSchema, + ModelThinkingLevelSchema, + StopReasonSchema, + TextContentSchema, + ThinkingContentSchema, + ThinkingLevelSchema, + ToolCallSchema, + UsageSchema, +} from "./schemas.ts"; import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts"; import type { AssistantMessageEventStream } from "./utils/event-stream.ts"; @@ -76,8 +88,8 @@ export type KnownImagesProvider = "openrouter"; export type ImagesProviderId = KnownImagesProvider | string; -export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; -export type ModelThinkingLevel = "off" | ThinkingLevel; +export type ThinkingLevel = Static; +export type ModelThinkingLevel = Static; export type ThinkingLevelMap = Partial>; export type ChatTemplateKwargValue = | string @@ -335,60 +347,12 @@ export interface TextSignatureV1 { phase?: "commentary" | "final_answer"; } -export interface TextContent { - type: "text"; - text: string; - textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON) -} - -export interface ThinkingContent { - type: "thinking"; - thinking: string; - thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID - /** When true, the thinking content was redacted by safety filters. The opaque - * encrypted payload is stored in `thinkingSignature` so it can be passed back - * to the API for multi-turn continuity. */ - redacted?: boolean; -} - -export interface ImageContent { - type: "image"; - data: string; // base64 encoded image data - mimeType: string; // e.g., "image/jpeg", "image/png" -} - -export interface ToolCall { - type: "toolCall"; - id: string; - name: string; - arguments: Record; - thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context -} - -export interface Usage { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - /** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */ - cacheWrite1h?: number; - /** - * Reasoning/thinking tokens, when the provider reports them. This is a subset of - * `output`: `output` already includes these tokens. Set to a number (possibly 0) by - * providers that expose a reasoning breakdown; left undefined by providers that don't. - */ - reasoning?: number; - totalTokens: number; - cost: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - total: number; - }; -} - -export type StopReason = "pending" | "stop" | "length" | "toolUse" | "error" | "aborted"; +export type TextContent = Static; +export type ThinkingContent = Static; +export type ImageContent = Static; +export type ToolCall = Static; +export type Usage = Static; +export type StopReason = Static; export interface UserMessage { role: "user"; @@ -453,8 +417,6 @@ export interface AssistantImages { timestamp: number; // Unix timestamp in milliseconds } -import type { TSchema } from "typebox"; - /** OpenAI grammar variants for constrained sampling. */ export type GrammarFormat = "openai_lark" | "openai_regex"; @@ -738,12 +700,7 @@ export interface VercelGatewayRouting { order?: string[]; } -export interface ModelCostRates { - input: number; // $/million tokens - output: number; // $/million tokens - cacheRead: number; // $/million tokens - cacheWrite: number; // $/million tokens -} +export type ModelCostRates = Static; export interface ModelCostTier extends ModelCostRates { /** Use this tier for requests whose total input usage exceeds this token count. */ diff --git a/packages/protocol/package.json b/packages/protocol/package.json index da7189ad0..3686e1818 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -27,6 +27,9 @@ "directory": "packages/protocol" }, "engines": { "node": ">=22.19.0" }, - "dependencies": { "typebox": "1.3.7" }, + "dependencies": { + "@earendil-works/pi-ai": "^0.83.0", + "typebox": "1.3.7" + }, "devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" } } diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 16cde6320..cc5e88cfc 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -1,3 +1,13 @@ +import { + ImageContentSchema as AiImageContentSchema, + TextContentSchema as AiTextContentSchema, + ThinkingContentSchema as AiThinkingContentSchema, + UsageSchema as AiUsageSchema, + ModelCostRatesSchema, + ModelThinkingLevelSchema, + StopReasonSchema, + ToolCallSchema, +} from "@earendil-works/pi-ai/schemas"; import Type, { type Static } from "typebox"; export const PROTOCOL_VERSION = 2 as const; @@ -23,15 +33,7 @@ const JsonValueRecursiveSchema = Type.Cyclic( ); export const JsonValueSchema = Type.Unsafe(JsonValueRecursiveSchema); -export const ThinkingLevelSchema = Type.Union([ - Type.Literal("off"), - Type.Literal("minimal"), - Type.Literal("low"), - Type.Literal("medium"), - Type.Literal("high"), - Type.Literal("xhigh"), - Type.Literal("max"), -]); +export const ThinkingLevelSchema = ModelThinkingLevelSchema; export type ThinkingLevel = Static; /** Matches AgentHarnessPhase so adapters do not need a second phase vocabulary. */ @@ -50,12 +52,7 @@ export const ModelRefSchema = StrictObject({ }); export type ModelRef = Static; -export const ModelCostSchema = StrictObject({ - input: Type.Number({ minimum: 0 }), - output: Type.Number({ minimum: 0 }), - cacheRead: Type.Number({ minimum: 0 }), - cacheWrite: Type.Number({ minimum: 0 }), -}); +export const ModelCostSchema = ModelCostRatesSchema; export const ModelMetadataSchema = StrictObject({ provider: IdSchema, @@ -72,26 +69,18 @@ export const ModelMetadataSchema = StrictObject({ }); export type ModelMetadata = Static; -export const TextContentSchema = StrictObject({ - type: Type.Literal("text"), - text: Type.String(), -}); -export const ThinkingContentSchema = StrictObject({ - type: Type.Literal("thinking"), - thinking: Type.String(), - redacted: Type.Optional(Type.Boolean()), -}); -export const ImageContentSchema = StrictObject({ - type: Type.Literal("image"), - data: Type.String(), - mimeType: Type.String({ minLength: 1 }), -}); -export const ToolCallContentSchema = StrictObject({ - type: Type.Literal("tool_call"), - toolCallId: IdSchema, - toolName: IdSchema, - input: JsonValueSchema, -}); +export const TextContentSchema = Type.Omit(AiTextContentSchema, ["textSignature"]); +export const ThinkingContentSchema = Type.Omit(AiThinkingContentSchema, ["thinkingSignature"]); +export const ImageContentSchema = AiImageContentSchema; +export const ToolCallContentSchema = Type.Omit( + StrictObject({ + ...ToolCallSchema.properties, + id: IdSchema, + name: IdSchema, + arguments: Type.Record(Type.String(), JsonValueSchema), + }), + ["thoughtSignature"], +); export const UserContentSchema = Type.Union([TextContentSchema, ImageContentSchema]); export const AssistantContentSchema = Type.Union([TextContentSchema, ThinkingContentSchema, ToolCallContentSchema]); export const ToolContentSchema = Type.Union([TextContentSchema, ImageContentSchema]); @@ -100,21 +89,7 @@ export type ThinkingContent = Static; export type ImageContent = Static; export type ToolCallContent = Static; -export const UsageSchema = StrictObject({ - input: Type.Integer({ minimum: 0 }), - output: Type.Integer({ minimum: 0 }), - cacheRead: Type.Integer({ minimum: 0 }), - cacheWrite: Type.Integer({ minimum: 0 }), - reasoning: Type.Optional(Type.Integer({ minimum: 0 })), - totalTokens: Type.Integer({ minimum: 0 }), - cost: StrictObject({ - input: Type.Number({ minimum: 0 }), - output: Type.Number({ minimum: 0 }), - cacheRead: Type.Number({ minimum: 0 }), - cacheWrite: Type.Number({ minimum: 0 }), - total: Type.Number({ minimum: 0 }), - }), -}); +export const UsageSchema = AiUsageSchema; export type Usage = Static; export const UserTranscriptItemSchema = StrictObject({ @@ -136,15 +111,7 @@ export const AssistantTranscriptItemSchema = StrictObject({ model: ModelRefSchema, responseModel: Type.Optional(Type.String({ minLength: 1 })), usage: Type.Optional(UsageSchema), - stopReason: Type.Optional( - Type.Union([ - Type.Literal("stop"), - Type.Literal("length"), - Type.Literal("tool_use"), - Type.Literal("error"), - Type.Literal("aborted"), - ]), - ), + stopReason: Type.Optional(Type.Exclude(StopReasonSchema, Type.Literal("pending"))), errorMessage: Type.Optional(Type.String()), timestamp: TimestampSchema, }); diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index 23621ab47..74525ca86 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -111,6 +111,15 @@ describe("protocol validation", () => { input: { path: "/tmp/file" }, content: [{ type: "text", text: "done" }], details: { lines: [1, 2, 3], cached: false }, + usage: { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + cacheWrite1h: 2, + totalTokens: 10, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }, status: "complete", isError: false, timestamp: 1, @@ -121,6 +130,29 @@ describe("protocol validation", () => { expect(parseServerMessage(message)).toEqual(message); }); + test("uses pi-ai tool calls and stop reasons in transcript items", () => { + const message = { + type: "event", + event: { + type: "session_progress", + sessionId: "session-1", + progress: { + type: "item_finished", + item: { + id: "assistant-1", + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "/tmp/file" } }], + status: "complete", + model: { provider: "faux", id: "faux-model" }, + stopReason: "toolUse", + timestamp: 1, + }, + }, + }, + }; + expect(parseServerMessage(message)).toEqual(message); + }); + test("rejects cyclic protocol values with a protocol validation error", () => { const details: Record = {}; details.self = details; diff --git a/packages/protocol/vitest.config.ts b/packages/protocol/vitest.config.ts index 157f467a0..455e73658 100644 --- a/packages/protocol/vitest.config.ts +++ b/packages/protocol/vitest.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ @@ -6,4 +7,12 @@ export default defineConfig({ environment: "node", reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], }, + resolve: { + alias: [ + { + find: /^@earendil-works\/pi-ai\/schemas$/, + replacement: fileURLToPath(new URL("../ai/src/schemas.ts", import.meta.url)), + }, + ], + }, });