diff --git a/EVENTS.md b/EVENTS.md index 911798f..724d553 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -302,6 +302,67 @@ Emitted at step boundaries during multi-step tool use. { "type": "step_finish", "part": { "type": "step-finish" } } ``` +#### Provider termination details + +`step_finish.part.termination` is an optional additive diagnostic object. Its +`normalizedReason` mirrors `part.reason`; `providerID` and `modelID` identify the +configured provider/model. Correlate it with the enclosing `invocationID` and +`sessionID`, and `part.messageID`. Older stored parts may omit `termination`. +This diagnostic does not decide whether an invocation succeeded (see #108). + +```json +{ + "type": "step_finish", + "invocationID": "inv_fixture", + "sessionID": "ses_fixture", + "part": { + "type": "step-finish", + "messageID": "msg_fixture", + "reason": "error", + "termination": { + "providerID": "google-vertex", + "modelID": "gemini-2.5-flash", + "normalizedReason": "error", + "rawReason": { "status": "available", "value": "MALFORMED_FUNCTION_CALL", "truncated": false }, + "requestID": { "status": "unavailable", "truncated": false }, + "diagnostic": { "status": "redacted", "truncated": false } + } + } +} +``` + +The example omits unrelated step fields. Each diagnostic field reports +`available` (a permitted observed value), `unavailable` (missing or unsupported), +or `redacted` (present but suppressed). Values are absent when unavailable or +redacted. `truncated: true` means an oversized value was entirely suppressed; +no prefix is retained. Raw reasons and request IDs are limited to 128 characters. +Free-form provider diagnostics are always suppressed, with a 2,048-character +threshold for the oversize flag: they can contain credentials, prompt text, or +tool arguments that pattern-based redaction cannot reliably remove. + +Raw reasons are currently captured only for native `@ai-sdk/google` and +`@ai-sdk/google-vertex` streams. Their pinned adapters support internal raw +chunks; middleware selects the first candidate's allowlisted `finishReason`, +records `finishMessage` presence/size, and drops every raw chunk before it reaches +AI SDK stream consumers. Unknown raw reason strings are redacted. Other adapters +still provide their normalized reason and explicitly report raw details as +unavailable. No raw reason is inferred from the normalized reason. + +Request-ID availability uses only `x-request-id` or `x-goog-request-id` response +headers. All nonempty values are reported as `redacted` without retaining the +value, including ordinary IDs: arbitrary IDs cannot be reliably distinguished +from credential material by format. Values over 128 characters also set +`truncated: true`. The SDK's generated response ID is never presented as a +provider request ID. No new prompts, +reasoning, tool arguments, full responses, or response-header maps are collected +by this diagnostic path. Existing exception diagnostics are unchanged. + +For #109, this delivers observed raw finish reasons and privacy-preserving +diagnostic availability. Richer diagnostic text remains a separate policy and +adapter-coverage decision; a redacted diagnostic cannot identify the exact +offending tool call. Deliver after #108 so a provider error reason is paired +with truthful process/session failure status. + ## Skill Events Skills are loaded progressively. The model first sees skill names and descriptions in the tool schema. Full skill content only enters context when the model explicitly invokes the skill tool. diff --git a/packages/cli/src/cli/cmd/generate.ts b/packages/cli/src/cli/cmd/generate.ts index 47c6bac..3e4760a 100644 --- a/packages/cli/src/cli/cmd/generate.ts +++ b/packages/cli/src/cli/cmd/generate.ts @@ -1,8 +1,22 @@ import type { CommandModule } from "yargs" +import z from "zod" +import { MessageV2 } from "@/session/message-v2" +import { ProviderTermination } from "@/provider/termination" export const GenerateCommand = { command: "generate", handler: async () => { + // Keep OpenAPI component identities separate from legacy Zod metadata. + const registry = z.registry<{ id: string }>() + registry.add(MessageV2.StepFinishPart, { id: "StepFinishPart" }) + registry.add(ProviderTermination.Info, { id: "ProviderTermination" }) + const components = z.toJSONSchema(registry, { + metadata: z.registry(), + target: "draft-2020-12", + uri: (id) => `#/components/schemas/${id}`, + }) + // Component JSON pointers are references, not standalone schema resource IDs. + for (const schema of Object.values(components.schemas)) delete schema.$id const specs = { openapi: "3.1.1", info: { @@ -10,6 +24,7 @@ export const GenerateCommand = { version: "1.0.0", }, paths: {}, + components, } const json = JSON.stringify(specs, null, 2) diff --git a/packages/cli/src/provider/termination.ts b/packages/cli/src/provider/termination.ts new file mode 100644 index 0000000..1a706b7 --- /dev/null +++ b/packages/cli/src/provider/termination.ts @@ -0,0 +1,123 @@ +import type { LanguageModelV2Middleware } from "@ai-sdk/provider" +import z from "zod" + +export namespace ProviderTermination { + const VALUE_LIMIT = 128 + const DIAGNOSTIC_LIMIT = 2048 + function Field(limit: number) { + return z.object({ + status: z.enum(["available", "unavailable", "redacted"]), + value: z.string().max(limit).optional(), + truncated: z.boolean(), + }) + } + type Field = z.infer> + + export const Info = z + .object({ + providerID: z.string(), + modelID: z.string(), + normalizedReason: z.string(), + rawReason: Field(VALUE_LIMIT), + requestID: Field(VALUE_LIMIT), + diagnostic: Field(DIAGNOSTIC_LIMIT), + }) + .meta({ ref: "ProviderTermination" }) + export type Info = z.infer + + const reasons = new Set([ + "FINISH_REASON_UNSPECIFIED", + "STOP", + "MAX_TOKENS", + "SAFETY", + "RECITATION", + "LANGUAGE", + "OTHER", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", + "MALFORMED_FUNCTION_CALL", + "IMAGE_SAFETY", + "UNEXPECTED_TOOL_CALL", + "NO_IMAGE", + "IMAGE_PROHIBITED_CONTENT", + "IMAGE_OTHER", + "IMAGE_RECITATION", + ]) + + function field(value: unknown, allowed: (value: string) => boolean, limit = VALUE_LIMIT): Field { + if (typeof value !== "string" || !value) return { status: "unavailable", truncated: false } + if (value.length > limit || !allowed(value)) return { status: "redacted", truncated: value.length > limit } + return { status: "available", value, truncated: false } + } + + function record(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return + return value as Record + } + + export function middleware(input: { providerID: string; modelID: string; npm: string }): LanguageModelV2Middleware { + const google = input.npm === "@ai-sdk/google" || input.npm === "@ai-sdk/google-vertex" + return { + async transformParams({ type, params }) { + return type === "stream" && google ? { ...params, includeRawChunks: true } : params + }, + async wrapStream({ doStream }) { + const result = await doStream() + let rawReason = field(undefined, () => false) + let diagnostic = field(undefined, () => false) + const headers = result.response?.headers + // A provider/proxy can echo arbitrary credentials into an ID header. + // Format allowlists cannot distinguish an opaque ID from an opaque key. + const requestID = field(headers?.["x-request-id"] || headers?.["x-goog-request-id"], () => false) + return { + ...result, + stream: result.stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk.type === "raw") { + if (google) { + const candidates = record(chunk.rawValue)?.candidates + const candidate = Array.isArray(candidates) ? record(candidates[0]) : undefined + if (candidate?.finishReason != null) { + rawReason = field(candidate.finishReason, (value) => reasons.has(value)) + // Provider messages can contain prompts or tool arguments. Retain only + // presence/size information; do not collect their free-form contents. + diagnostic = field(candidate.finishMessage, () => false, DIAGNOSTIC_LIMIT) + } + } + // Raw payloads must never escape this boundary to stream consumers. + return + } + if (chunk.type !== "finish") { + controller.enqueue(chunk) + return + } + const termination: Info = { + providerID: input.providerID, + modelID: input.modelID, + normalizedReason: chunk.finishReason, + rawReason, + requestID, + diagnostic, + } + controller.enqueue({ + ...chunk, + providerMetadata: { + ...chunk.providerMetadata, + aictrl: { ...chunk.providerMetadata?.aictrl, termination }, + }, + }) + }, + }), + ), + } + }, + } + } + + export function from(metadata: unknown): Info | undefined { + const result = Info.safeParse(record(record(metadata)?.aictrl)?.termination) + return result.success ? result.data : undefined + } +} diff --git a/packages/cli/src/session/llm.ts b/packages/cli/src/session/llm.ts index 018d17b..2ff4ac2 100644 --- a/packages/cli/src/session/llm.ts +++ b/packages/cli/src/session/llm.ts @@ -22,6 +22,7 @@ import { SystemPrompt } from "./system" import { Flag } from "@/flag/flag" import { PermissionNext } from "@/permission/next" import { Auth } from "@/auth" +import { ProviderTermination } from "@/provider/termination" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -234,6 +235,11 @@ export namespace LLM { model: wrapLanguageModel({ model: language, middleware: [ + ProviderTermination.middleware({ + providerID: input.model.providerID, + modelID: input.model.id, + npm: input.model.api.npm, + }), { async transformParams(args) { if (args.type === "stream") { diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index 937f6f6..ed1f5ac 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -9,6 +9,7 @@ import { fn } from "@/util/fn" import { Database, eq, desc, inArray } from "@/storage/db" import { MessageTable, PartTable } from "./session.sql" import { ProviderTransform } from "@/provider/transform" +import { ProviderTermination } from "@/provider/termination" import { STATUS_CODES } from "http" import { Storage } from "@/storage/storage" import { ProviderError } from "@/provider/error" @@ -241,6 +242,7 @@ export namespace MessageV2 { export const StepFinishPart = PartBase.extend({ type: z.literal("step-finish"), reason: z.string(), + termination: ProviderTermination.Info.optional(), snapshot: z.string().optional(), cost: z.number(), tokens: z.object({ diff --git a/packages/cli/src/session/processor.ts b/packages/cli/src/session/processor.ts index 2577169..001314e 100644 --- a/packages/cli/src/session/processor.ts +++ b/packages/cli/src/session/processor.ts @@ -16,6 +16,7 @@ import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" import { NamedError } from "@aictrl/util/error" +import { ProviderTermination } from "@/provider/termination" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -255,6 +256,7 @@ export namespace SessionProcessor { await Session.updatePart({ id: Identifier.ascending("part"), reason: value.finishReason, + termination: ProviderTermination.from(value.providerMetadata), snapshot: await Snapshot.track(), messageID: input.assistantMessage.id, sessionID: input.assistantMessage.sessionID, diff --git a/packages/cli/test/cli/generate.test.ts b/packages/cli/test/cli/generate.test.ts new file mode 100644 index 0000000..4cd2984 --- /dev/null +++ b/packages/cli/test/cli/generate.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test" +import path from "path" + +test("generate publishes the termination schema used by SDK codegen", async () => { + const proc = Bun.spawn( + ["bun", "run", "--conditions=browser", path.resolve(import.meta.dir, "../../src/index.ts"), "generate"], + { + stdout: "pipe", + stderr: "pipe", + }, + ) + const [stdout, stderr, exit] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + expect(exit, stderr).toBe(0) + const spec = JSON.parse(stdout) + const part = spec.components.schemas.StepFinishPart + const diagnostic = spec.components.schemas.ProviderTermination + expect(spec.openapi).toBe("3.1.1") + expect(part.properties.termination).toEqual({ $ref: "#/components/schemas/ProviderTermination" }) + expect(part.$schema).toBe("https://json-schema.org/draft/2020-12/schema") + expect(diagnostic.$schema).toBe("https://json-schema.org/draft/2020-12/schema") + expect(part.id).toBeUndefined() + expect(diagnostic.id).toBeUndefined() + expect(part.$id).toBeUndefined() + expect(diagnostic.$id).toBeUndefined() + expect(part.properties.type.const).toBe("step-finish") + expect(stdout).not.toMatch(/"ref"\s*:/) + expect(stdout).not.toMatch(/"nullable"\s*:/) + expect(part.required).not.toContain("termination") + expect(diagnostic.required).toContain("normalizedReason") + expect(diagnostic.properties.rawReason.properties.value.maxLength).toBe(128) + expect(diagnostic.properties.diagnostic.properties.value.maxLength).toBe(2048) + expect(diagnostic.properties.requestID.properties.status.enum).toEqual(["available", "unavailable", "redacted"]) +}) diff --git a/packages/cli/test/cli/run-termination.test.ts b/packages/cli/test/cli/run-termination.test.ts new file mode 100644 index 0000000..f2db90b --- /dev/null +++ b/packages/cli/test/cli/run-termination.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { MessageV2 } from "../../src/session/message-v2" +import type { StepFinishPart } from "../../../sdk/src/v2/gen/types.gen" +import type { StepFinishPart as LegacyStepFinishPart } from "../../../sdk/src/gen/types.gen" + +test("provider termination survives adapter, storage, and headless NDJSON without raw payloads", async () => { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response( + `data: ${JSON.stringify({ + candidates: [ + { + index: 0, + finishReason: "MALFORMED_FUNCTION_CALL", + finishMessage: "Invalid arguments: api_key=private-fixture-value " + "x".repeat(3000), + }, + ], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + })}\n\n`, + { headers: { "content-type": "text/event-stream", "x-request-id": "xoxb-private-fixture-value" } }, + ) + }, + }) + await using tmp = await tmpdir({ + config: { + provider: { + fixture: { + npm: "@ai-sdk/google", + options: { apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}` }, + models: { "gemini-fixture": { name: "fixture", limit: { context: 100000, output: 1000 } } }, + }, + }, + agent: { title: { disable: true } }, + }, + }) + const proc = Bun.spawn( + [ + "bun", + "run", + "--conditions=browser", + path.resolve(import.meta.dir, "../../src/index.ts"), + "run", + "--format", + "json", + "--model", + "fixture/gemini-fixture", + "Synthetic diagnostic fixture", + ], + { + cwd: tmp.path, + env: { + ...process.env, + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + AICTRL_DISABLE_MODELS_FETCH: "true", + AICTRL_DISABLE_AUTOCOMPACT: "true", + }, + stdout: "pipe", + stderr: "pipe", + }, + ) + const timeout = setTimeout(() => proc.kill("SIGKILL"), 15000) + try { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + const events = stdout + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line)) + const finish = events.find((event) => event.type === "step_finish") + expect(finish, stderr + stdout).toBeDefined() + const part: StepFinishPart = MessageV2.StepFinishPart.parse(finish.part) + const legacy: LegacyStepFinishPart = part + expect(legacy.termination).toEqual({ + providerID: "fixture", + modelID: "gemini-fixture", + normalizedReason: "error", + rawReason: { status: "available", value: "MALFORMED_FUNCTION_CALL", truncated: false }, + requestID: { status: "redacted", truncated: false }, + diagnostic: { status: "redacted", truncated: true }, + }) + expect(finish.part.sessionID).toBe(finish.sessionID) + expect(finish.part.messageID).toBeString() + expect(finish.invocationID).toBe(events.find((event) => event.type === "invocation_complete").invocationID) + expect(stdout + stderr).not.toContain("private-fixture-value") + expect(events.some((event) => event.type === "raw")).toBe(false) + } finally { + clearTimeout(timeout) + proc.kill("SIGKILL") + server.stop(true) + } +}, 20000) diff --git a/packages/cli/test/provider/termination.test.ts b/packages/cli/test/provider/termination.test.ts new file mode 100644 index 0000000..bbfc0f1 --- /dev/null +++ b/packages/cli/test/provider/termination.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test" +import { createGoogleGenerativeAI } from "@ai-sdk/google" +import { createVertex } from "@ai-sdk/google-vertex" +import { OAuth2Client } from "google-auth-library" +import { streamText, wrapLanguageModel } from "ai" +import { ProviderTermination } from "../../src/provider/termination" + +function response( + reason = "MALFORMED_FUNCTION_CALL", + message: unknown = "Invalid call: token=secret-value", + request = "req_123", + googleRequest = "", +) { + return new Response( + `data: ${JSON.stringify({ + candidates: [{ index: 0, finishReason: reason, finishMessage: message }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + })}\n\n`, + { headers: { "content-type": "text/event-stream", "x-request-id": request, "x-goog-request-id": googleRequest } }, + ) +} + +async function capture( + options: { + vertex?: boolean + reason?: string + message?: unknown + request?: string + googleRequest?: string + npm?: string + } = {}, +) { + const auth = new OAuth2Client() + auth.setCredentials({ access_token: "synthetic-test-token", expiry_date: Date.now() + 3600000 }) + const fetcher = Object.assign( + async () => response(options.reason, options.message, options.request, options.googleRequest), + { + preconnect: fetch.preconnect, + }, + ) + const model = options.vertex + ? createVertex({ + project: "fixture", + location: "us-central1", + googleAuthOptions: { authClient: auth }, + fetch: fetcher, + })("gemini-2.5-flash") + : createGoogleGenerativeAI({ apiKey: "fixture", fetch: fetcher })("gemini-2.5-flash") + const result = streamText({ + model: wrapLanguageModel({ + model, + middleware: ProviderTermination.middleware({ + providerID: "fixture", + modelID: "gemini-2.5-flash", + npm: options.npm ?? (options.vertex ? "@ai-sdk/google-vertex" : "@ai-sdk/google"), + }), + }), + prompt: "Synthetic fixture", + maxRetries: 0, + }) + const chunks = await Array.fromAsync(result.fullStream) + const finish = chunks.find((chunk) => chunk.type === "finish-step") + expect(finish).toBeDefined() + expect(chunks.some((chunk) => chunk.type === "raw")).toBe(false) + return { chunks, termination: ProviderTermination.from(finish?.providerMetadata)! } +} + +describe("provider termination diagnostics", () => { + test.each([false, true])( + "preserves observed raw reason through actual Google/Vertex adapter (Vertex=%s)", + async (vertex) => { + const { chunks, termination } = await capture({ vertex }) + expect(termination).toEqual({ + providerID: "fixture", + modelID: "gemini-2.5-flash", + normalizedReason: "error", + rawReason: { status: "available", value: "MALFORMED_FUNCTION_CALL", truncated: false }, + requestID: { status: "redacted", truncated: false }, + diagnostic: { status: "redacted", truncated: false }, + }) + expect(JSON.stringify(chunks)).not.toContain("secret-value") + }, + ) + + test("suppresses full free-form diagnostics, bounds oversize values, and redacts credential-shaped IDs", async () => { + const { chunks, termination } = await capture({ + message: "Bearer secret-token ".repeat(500), + request: "sk-credential-value", + }) + expect(termination.diagnostic).toEqual({ status: "redacted", truncated: true }) + expect(termination.requestID).toEqual({ status: "redacted", truncated: false }) + expect(JSON.stringify(chunks)).not.toContain("secret-token") + expect(JSON.stringify(termination)).not.toContain("credential-value") + expect(JSON.stringify(termination).length).toBeLessThan(1024) + }) + + test("reports absent diagnostics and unknown raw enums without fabricating details", async () => { + const { termination } = await capture({ reason: "UNRECOGNIZED_REASON", message: null, request: "" }) + expect(termination.normalizedReason).toBe("unknown") + expect(termination.rawReason).toEqual({ status: "redacted", truncated: false }) + expect(termination.requestID).toEqual({ status: "unavailable", truncated: false }) + expect(termination.diagnostic).toEqual({ status: "unavailable", truncated: false }) + }) + + test.each(["google-request", "g".repeat(129)])( + "falls back from an empty primary ID to a nonempty Google ID", + async (googleRequest) => { + const { termination } = await capture({ request: "", googleRequest }) + expect(termination.requestID).toEqual({ status: "redacted", truncated: googleRequest.length > 128 }) + expect(JSON.stringify(termination)).not.toContain(googleRequest) + }, + ) + + test("diagnostic schema accepts its declared bound while runtime text remains suppressed", async () => { + const { termination } = await capture({ message: "x".repeat(2048) }) + expect(termination.diagnostic).toEqual({ status: "redacted", truncated: false }) + const allowed = { ...termination, diagnostic: { status: "available", value: "x".repeat(2048), truncated: false } } + expect(ProviderTermination.from({ aictrl: { termination: allowed } })?.diagnostic.value).toHaveLength(2048) + const excessive = { ...allowed, diagnostic: { ...allowed.diagnostic, value: "x".repeat(2049) } } + expect(ProviderTermination.from({ aictrl: { termination: excessive } })).toBeUndefined() + const oversize = await capture({ message: "x".repeat(2049) }) + expect(oversize.termination.diagnostic).toEqual({ status: "redacted", truncated: true }) + expect( + ProviderTermination.from({ aictrl: { termination: { ...allowed, rawReason: allowed.diagnostic } } }), + ).toBeUndefined() + }) + + test.each([ + "xoxb-slack-token", + "xoxp-slack-token", + "glpat-gitlab-token", + "npm_registry_token", + "0123456789abcdef0123456789abcdef", + "cHJlZml4bGVzcy1zZWNyZXQ", + "req_ordinary_request_id", + "x".repeat(129), + ])("does not persist arbitrary request ID values (%s)", async (request) => { + const { termination } = await capture({ request }) + expect(termination.requestID).toEqual({ status: "redacted", truncated: request.length > 128 }) + expect(JSON.stringify(termination)).not.toContain(request) + }) + + test("ignores malformed metadata rather than replacing execution errors", () => { + expect(ProviderTermination.from(null)).toBeUndefined() + expect(ProviderTermination.from({ aictrl: { termination: { rawReason: "error" } } })).toBeUndefined() + }) + + test("supports adapters with only normalized finish metadata", async () => { + const { termination } = await capture({ npm: "@ai-sdk/unsupported-fixture" }) + expect(termination.normalizedReason).toBe("error") + expect(termination.rawReason).toEqual({ status: "unavailable", truncated: false }) + expect(termination.diagnostic).toEqual({ status: "unavailable", truncated: false }) + }) + + test("preserves adapter errors without producing a false finish", async () => { + const result = streamText({ + model: wrapLanguageModel({ + model: createGoogleGenerativeAI({ + apiKey: "fixture", + fetch: Object.assign( + async () => + new Response( + JSON.stringify({ error: { code: 401, message: "synthetic unauthorized", status: "UNAUTHENTICATED" } }), + { status: 401 }, + ), + { preconnect: fetch.preconnect }, + ), + })("gemini-2.5-flash"), + middleware: ProviderTermination.middleware({ + providerID: "fixture", + modelID: "gemini-2.5-flash", + npm: "@ai-sdk/google", + }), + }), + prompt: "Synthetic fixture", + maxRetries: 0, + onError() {}, + }) + const chunks = await Array.fromAsync(result.fullStream) + const failure = chunks.find((chunk) => chunk.type === "error") + expect(failure?.error).toMatchObject({ statusCode: 401, message: "synthetic unauthorized" }) + expect(chunks.some((chunk) => chunk.type === "finish-step")).toBe(false) + }) +}) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 499c84e..f7e34ac 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -61,4 +61,18 @@ for await (const event of events.stream) { ## Documentation +### Regenerating event types + +Run `bun run packages/sdk/script/build.ts` from the repository root. The build +runs the CLI `generate` command, which publishes the `StepFinishPart` and +`ProviderTermination` components from the runtime Zod schemas, then generates +`src/v2/gen/types.gen.ts`. Those types are exported from `@aictrl/sdk/v2`. + +The legacy SDK still uses the historical files in `src/gen`. The build maintains +a `StepFinishPart` type alias there to the canonical generated v2 type, so its +event unions inherit the same additive termination fields. Edit the runtime +schemas and rebuild; do not hand-edit either generated declaration. The temporary +`openapi.json` is deleted after a successful build; the repository does not use +a checked-in `docs/architecture/openapi.yaml` contract. + For more information, visit [aictrl.ai](https://aictrl.ai). diff --git a/packages/sdk/script/build.ts b/packages/sdk/script/build.ts index 691340e..3653753 100755 --- a/packages/sdk/script/build.ts +++ b/packages/sdk/script/build.ts @@ -38,6 +38,18 @@ await createClient({ ], }) +// The headless generator writes v2 types; the legacy SDK client still refers to +// src/gen/types.gen.ts. Bridge this shared part to its canonical generated type +// so event unions in both SDK versions retain the same termination contract. +const legacy = Bun.file("./src/gen/types.gen.ts") +const source = await legacy.text() +const pattern = + /^export type StepFinishPart = (?:\{[\s\S]*?^\}|import\("\.\.\/v2\/gen\/types\.gen\.js"\)\.StepFinishPart)$/m +if (!pattern.test(source)) throw new Error("Legacy StepFinishPart declaration not found; update the SDK schema bridge") +await legacy.write( + source.replace(pattern, 'export type StepFinishPart = import("../v2/gen/types.gen.js").StepFinishPart'), +) + await $`bun prettier --write src/gen` await $`bun prettier --write src/v2` await $`rm -rf dist` diff --git a/packages/sdk/src/gen/types.gen.ts b/packages/sdk/src/gen/types.gen.ts index 5b92b1b..895ad13 100644 --- a/packages/sdk/src/gen/types.gen.ts +++ b/packages/sdk/src/gen/types.gen.ts @@ -312,24 +312,7 @@ export type StepStartPart = { snapshot?: string } -export type StepFinishPart = { - id: string - sessionID: string - messageID: string - type: "step-finish" - reason: string - snapshot?: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } -} +export type StepFinishPart = import("../v2/gen/types.gen.js").StepFinishPart export type SnapshotPart = { id: string diff --git a/packages/sdk/src/v2/gen/types.gen.ts b/packages/sdk/src/v2/gen/types.gen.ts index a9a87a5..8a91f5c 100644 --- a/packages/sdk/src/v2/gen/types.gen.ts +++ b/packages/sdk/src/v2/gen/types.gen.ts @@ -3,3 +3,45 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } + +export type StepFinishPart = { + id: string + sessionID: string + messageID: string + type: "step-finish" + reason: string + termination?: ProviderTermination + snapshot?: string + cost: number + tokens: { + total?: number + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } +} + +export type ProviderTermination = { + providerID: string + modelID: string + normalizedReason: string + rawReason: { + status: "available" | "unavailable" | "redacted" + value?: string + truncated: boolean + } + requestID: { + status: "available" | "unavailable" | "redacted" + value?: string + truncated: boolean + } + diagnostic: { + status: "available" | "unavailable" | "redacted" + value?: string + truncated: boolean + } +} diff --git a/packages/sdk/src/v2/index.ts b/packages/sdk/src/v2/index.ts index ca47b31..1dde166 100644 --- a/packages/sdk/src/v2/index.ts +++ b/packages/sdk/src/v2/index.ts @@ -2,3 +2,4 @@ export { createAictrlClient } from "../client.js" export * from "./manual-types.js" export * from "./gen/client/index.js" export * from "./gen/client/types.gen.js" +export type { StepFinishPart, ProviderTermination } from "./gen/types.gen.js"