feat(ai): share runtime schemas with protocol

This commit is contained in:
Christian Klotz 2026-07-31 00:00:57 +03:00
parent 393c344229
commit 411fe0feae
No known key found for this signature in database
9 changed files with 180 additions and 124 deletions

1
package-lock.json generated
View file

@ -5678,6 +5678,7 @@
"version": "0.83.0",
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "^0.83.0",
"typebox": "1.3.7"
},
"devDependencies": {

View file

@ -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"

View file

@ -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";

View file

@ -0,0 +1,82 @@
import Type from "typebox";
const StrictObject = <T extends Parameters<typeof Type.Object>[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 }),
});

View file

@ -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<typeof ThinkingLevelSchema>;
export type ModelThinkingLevel = Static<typeof ModelThinkingLevelSchema>;
export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>;
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<string, any>;
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<typeof TextContentSchema>;
export type ThinkingContent = Static<typeof ThinkingContentSchema>;
export type ImageContent = Static<typeof ImageContentSchema>;
export type ToolCall = Static<typeof ToolCallSchema>;
export type Usage = Static<typeof UsageSchema>;
export type StopReason = Static<typeof StopReasonSchema>;
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<typeof ModelCostRatesSchema>;
export interface ModelCostTier extends ModelCostRates {
/** Use this tier for requests whose total input usage exceeds this token count. */

View file

@ -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" }
}

View file

@ -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<JsonValue>(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<typeof ThinkingLevelSchema>;
/** Matches AgentHarnessPhase so adapters do not need a second phase vocabulary. */
@ -50,12 +52,7 @@ export const ModelRefSchema = StrictObject({
});
export type ModelRef = Static<typeof ModelRefSchema>;
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<typeof ModelMetadataSchema>;
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<typeof ThinkingContentSchema>;
export type ImageContent = Static<typeof ImageContentSchema>;
export type ToolCallContent = Static<typeof ToolCallContentSchema>;
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<typeof UsageSchema>;
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,
});

View file

@ -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<string, unknown> = {};
details.self = details;

View file

@ -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)),
},
],
},
});