From 206c0e43011da652390bba909e0e6d6d69ac3817 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 12:15:57 +0100 Subject: [PATCH 1/4] feat(cli): preserve safe provider termination diagnostics --- EVENTS.md | 57 ++++++++ packages/cli/src/provider/termination.ts | 119 +++++++++++++++ packages/cli/src/session/llm.ts | 6 + packages/cli/src/session/message-v2.ts | 2 + packages/cli/src/session/processor.ts | 2 + packages/cli/test/cli/run-termination.test.ts | 92 ++++++++++++ .../cli/test/provider/termination.test.ts | 135 ++++++++++++++++++ packages/sdk/src/gen/types.gen.ts | 8 ++ 8 files changed, 421 insertions(+) create mode 100644 packages/cli/src/provider/termination.ts create mode 100644 packages/cli/test/cli/run-termination.test.ts create mode 100644 packages/cli/test/provider/termination.test.ts diff --git a/EVENTS.md b/EVENTS.md index 911798f..1252b75 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -295,6 +295,63 @@ For tools executed inside a subagent, `part.sessionID` will differ from the top- ### `step_start` / `step_finish` +`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 identity uses only `x-request-id` or `x-goog-request-id` response headers, +when present and composed of bounded alphanumeric, underscore, or hyphen +characters. Common credential prefixes are suppressed. 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. + Emitted at step boundaries during multi-step tool use. ```json diff --git a/packages/cli/src/provider/termination.ts b/packages/cli/src/provider/termination.ts new file mode 100644 index 0000000..77612c1 --- /dev/null +++ b/packages/cli/src/provider/termination.ts @@ -0,0 +1,119 @@ +import type { LanguageModelV2Middleware } from "@ai-sdk/provider" +import z from "zod" + +export namespace ProviderTermination { + const Field = z.object({ + status: z.enum(["available", "unavailable", "redacted"]), + value: z.string().max(128).optional(), + truncated: z.boolean(), + }) + + export const Info = z + .object({ + providerID: z.string(), + modelID: z.string(), + normalizedReason: z.string(), + rawReason: Field, + requestID: Field, + diagnostic: Field, + }) + .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 = 128): z.infer { + 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 + const requestID = field( + headers?.["x-request-id"] ?? headers?.["x-goog-request-id"], + (value) => /^[a-zA-Z0-9_-]+$/.test(value) && !/^(sk-|gh[pousr]_|github_pat_|AIza|AKIA|ASIA)/.test(value), + ) + 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, 2048) + } + } + // 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..cea8ce6 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -241,6 +241,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({ @@ -898,3 +899,4 @@ export namespace MessageV2 { } } } +import { ProviderTermination } from "@/provider/termination" 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/run-termination.test.ts b/packages/cli/test/cli/run-termination.test.ts new file mode 100644 index 0000000..15e33a1 --- /dev/null +++ b/packages/cli/test/cli/run-termination.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test" +import path from "path" +import { tmpdir } from "../fixture/fixture" + +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": "req_fixture" } }, + ) + }, + }) + 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() + expect(finish.part.termination).toEqual({ + providerID: "fixture", + modelID: "gemini-fixture", + normalizedReason: "error", + rawReason: { status: "available", value: "MALFORMED_FUNCTION_CALL", truncated: false }, + requestID: { status: "available", value: "req_fixture", 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..4e95cbd --- /dev/null +++ b/packages/cli/test/provider/termination.test.ts @@ -0,0 +1,135 @@ +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", +) { + 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 } }, + ) +} + +async function capture( + options: { vertex?: boolean; reason?: string; message?: unknown; request?: 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), { + 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: "available", value: "req_123", 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("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/src/gen/types.gen.ts b/packages/sdk/src/gen/types.gen.ts index 5b92b1b..cd865b8 100644 --- a/packages/sdk/src/gen/types.gen.ts +++ b/packages/sdk/src/gen/types.gen.ts @@ -318,6 +318,14 @@ export type StepFinishPart = { messageID: string type: "step-finish" reason: string + termination?: { + 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 } + } snapshot?: string cost: number tokens: { From 52dcaebf74faf7e15d2c3de403835336b62ef940 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 17:19:24 +0100 Subject: [PATCH 2/4] fix(cli): suppress request ID payloads in termination metadata --- EVENTS.md | 26 +++++++++++-------- packages/cli/src/provider/termination.ts | 7 +++-- packages/cli/src/session/message-v2.ts | 2 +- .../cli/test/provider/termination.test.ts | 17 +++++++++++- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 1252b75..724d553 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -295,6 +295,15 @@ For tools executed inside a subagent, `part.sessionID` will differ from the top- ### `step_start` / `step_finish` +Emitted at step boundaries during multi-step tool use. + +```json +{ "type": "step_start", "part": { "type": "step-start" } } +{ "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 @@ -339,10 +348,12 @@ 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 identity uses only `x-request-id` or `x-goog-request-id` response headers, -when present and composed of bounded alphanumeric, underscore, or hyphen -characters. Common credential prefixes are suppressed. The SDK's generated -response ID is never presented as a provider request ID. No new prompts, +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. @@ -352,13 +363,6 @@ 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. -Emitted at step boundaries during multi-step tool use. - -```json -{ "type": "step_start", "part": { "type": "step-start" } } -{ "type": "step_finish", "part": { "type": "step-finish" } } -``` - ## 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/provider/termination.ts b/packages/cli/src/provider/termination.ts index 77612c1..8a35aa3 100644 --- a/packages/cli/src/provider/termination.ts +++ b/packages/cli/src/provider/termination.ts @@ -62,10 +62,9 @@ export namespace ProviderTermination { let rawReason = field(undefined, () => false) let diagnostic = field(undefined, () => false) const headers = result.response?.headers - const requestID = field( - headers?.["x-request-id"] ?? headers?.["x-goog-request-id"], - (value) => /^[a-zA-Z0-9_-]+$/.test(value) && !/^(sk-|gh[pousr]_|github_pat_|AIza|AKIA|ASIA)/.test(value), - ) + // 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( diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index cea8ce6..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" @@ -899,4 +900,3 @@ export namespace MessageV2 { } } } -import { ProviderTermination } from "@/provider/termination" diff --git a/packages/cli/test/provider/termination.test.ts b/packages/cli/test/provider/termination.test.ts index 4e95cbd..250178c 100644 --- a/packages/cli/test/provider/termination.test.ts +++ b/packages/cli/test/provider/termination.test.ts @@ -64,7 +64,7 @@ describe("provider termination diagnostics", () => { modelID: "gemini-2.5-flash", normalizedReason: "error", rawReason: { status: "available", value: "MALFORMED_FUNCTION_CALL", truncated: false }, - requestID: { status: "available", value: "req_123", truncated: false }, + requestID: { status: "redacted", truncated: false }, diagnostic: { status: "redacted", truncated: false }, }) expect(JSON.stringify(chunks)).not.toContain("secret-value") @@ -91,6 +91,21 @@ describe("provider termination diagnostics", () => { expect(termination.diagnostic).toEqual({ status: "unavailable", truncated: false }) }) + 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() From be1bcb4b8c63bee85bb4ea1a880dc08dfe16ed5c Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 17:19:24 +0100 Subject: [PATCH 3/4] fix(sdk): generate termination types from runtime schemas --- packages/cli/src/cli/cmd/generate.ts | 9 +++ packages/cli/test/cli/generate.test.ts | 27 ++++++++ packages/cli/test/cli/run-termination.test.ts | 11 +++- packages/sdk/README.md | 14 +++++ packages/sdk/script/build.ts | 12 ++++ packages/sdk/src/gen/types.gen.ts | 27 +------- packages/sdk/src/v2/gen/types.gen.ts | 61 +++++++++++++++++++ packages/sdk/src/v2/index.ts | 1 + 8 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 packages/cli/test/cli/generate.test.ts diff --git a/packages/cli/src/cli/cmd/generate.ts b/packages/cli/src/cli/cmd/generate.ts index 47c6bac..2d4197c 100644 --- a/packages/cli/src/cli/cmd/generate.ts +++ b/packages/cli/src/cli/cmd/generate.ts @@ -1,4 +1,7 @@ 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", @@ -10,6 +13,12 @@ export const GenerateCommand = { version: "1.0.0", }, paths: {}, + components: { + schemas: { + StepFinishPart: z.toJSONSchema(MessageV2.StepFinishPart, { target: "openapi-3.0" }), + ProviderTermination: z.toJSONSchema(ProviderTermination.Info, { target: "openapi-3.0" }), + }, + }, } const json = JSON.stringify(specs, null, 2) diff --git a/packages/cli/test/cli/generate.test.ts b/packages/cli/test/cli/generate.test.ts new file mode 100644 index 0000000..3ef587b --- /dev/null +++ b/packages/cli/test/cli/generate.test.ts @@ -0,0 +1,27 @@ +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(diagnostic) + expect(part.required).not.toContain("termination") + expect(diagnostic.required).toContain("normalizedReason") + expect(diagnostic.properties.rawReason.properties.value.maxLength).toBe(128) + 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 index 15e33a1..f2db90b 100644 --- a/packages/cli/test/cli/run-termination.test.ts +++ b/packages/cli/test/cli/run-termination.test.ts @@ -1,6 +1,9 @@ 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({ @@ -17,7 +20,7 @@ test("provider termination survives adapter, storage, and headless NDJSON withou ], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, })}\n\n`, - { headers: { "content-type": "text/event-stream", "x-request-id": "req_fixture" } }, + { headers: { "content-type": "text/event-stream", "x-request-id": "xoxb-private-fixture-value" } }, ) }, }) @@ -71,12 +74,14 @@ test("provider termination survives adapter, storage, and headless NDJSON withou .map((line) => JSON.parse(line)) const finish = events.find((event) => event.type === "step_finish") expect(finish, stderr + stdout).toBeDefined() - expect(finish.part.termination).toEqual({ + 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: "available", value: "req_fixture", truncated: false }, + requestID: { status: "redacted", truncated: false }, diagnostic: { status: "redacted", truncated: true }, }) expect(finish.part.sessionID).toBe(finish.sessionID) 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 cd865b8..895ad13 100644 --- a/packages/sdk/src/gen/types.gen.ts +++ b/packages/sdk/src/gen/types.gen.ts @@ -312,32 +312,7 @@ export type StepStartPart = { snapshot?: string } -export type StepFinishPart = { - id: string - sessionID: string - messageID: string - type: "step-finish" - reason: string - termination?: { - 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 } - } - 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..dc8835b 100644 --- a/packages/sdk/src/v2/gen/types.gen.ts +++ b/packages/sdk/src/v2/gen/types.gen.ts @@ -3,3 +3,64 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } + +export type StepFinishPart = { + id: string + sessionID: string + messageID: string + type: "step-finish" + reason: string + termination?: { + 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 + } + } + 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" From c1d173cacc3d41ca6838a98d0d697e7a9df6bc31 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Mon, 14 Sep 2026 18:10:37 +0100 Subject: [PATCH 4/4] fix(cli): align termination schemas and availability metadata --- packages/cli/src/cli/cmd/generate.ts | 18 +++++--- packages/cli/src/provider/termination.ts | 27 +++++++----- packages/cli/test/cli/generate.test.ts | 12 ++++- .../cli/test/provider/termination.test.ts | 44 ++++++++++++++++--- packages/sdk/src/v2/gen/types.gen.ts | 21 +-------- 5 files changed, 79 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/cli/cmd/generate.ts b/packages/cli/src/cli/cmd/generate.ts index 2d4197c..3e4760a 100644 --- a/packages/cli/src/cli/cmd/generate.ts +++ b/packages/cli/src/cli/cmd/generate.ts @@ -6,6 +6,17 @@ 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: { @@ -13,12 +24,7 @@ export const GenerateCommand = { version: "1.0.0", }, paths: {}, - components: { - schemas: { - StepFinishPart: z.toJSONSchema(MessageV2.StepFinishPart, { target: "openapi-3.0" }), - ProviderTermination: z.toJSONSchema(ProviderTermination.Info, { target: "openapi-3.0" }), - }, - }, + components, } const json = JSON.stringify(specs, null, 2) diff --git a/packages/cli/src/provider/termination.ts b/packages/cli/src/provider/termination.ts index 8a35aa3..1a706b7 100644 --- a/packages/cli/src/provider/termination.ts +++ b/packages/cli/src/provider/termination.ts @@ -2,20 +2,25 @@ import type { LanguageModelV2Middleware } from "@ai-sdk/provider" import z from "zod" export namespace ProviderTermination { - const Field = z.object({ - status: z.enum(["available", "unavailable", "redacted"]), - value: z.string().max(128).optional(), - truncated: z.boolean(), - }) + 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, - requestID: Field, - diagnostic: Field, + rawReason: Field(VALUE_LIMIT), + requestID: Field(VALUE_LIMIT), + diagnostic: Field(DIAGNOSTIC_LIMIT), }) .meta({ ref: "ProviderTermination" }) export type Info = z.infer @@ -40,7 +45,7 @@ export namespace ProviderTermination { "IMAGE_RECITATION", ]) - function field(value: unknown, allowed: (value: string) => boolean, limit = 128): z.infer { + 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 } @@ -64,7 +69,7 @@ export namespace ProviderTermination { 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) + const requestID = field(headers?.["x-request-id"] || headers?.["x-goog-request-id"], () => false) return { ...result, stream: result.stream.pipeThrough( @@ -78,7 +83,7 @@ export namespace ProviderTermination { 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, 2048) + diagnostic = field(candidate.finishMessage, () => false, DIAGNOSTIC_LIMIT) } } // Raw payloads must never escape this boundary to stream consumers. diff --git a/packages/cli/test/cli/generate.test.ts b/packages/cli/test/cli/generate.test.ts index 3ef587b..4cd2984 100644 --- a/packages/cli/test/cli/generate.test.ts +++ b/packages/cli/test/cli/generate.test.ts @@ -19,9 +19,19 @@ test("generate publishes the termination schema used by SDK codegen", async () = const part = spec.components.schemas.StepFinishPart const diagnostic = spec.components.schemas.ProviderTermination expect(spec.openapi).toBe("3.1.1") - expect(part.properties.termination).toEqual(diagnostic) + 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/provider/termination.test.ts b/packages/cli/test/provider/termination.test.ts index 250178c..bbfc0f1 100644 --- a/packages/cli/test/provider/termination.test.ts +++ b/packages/cli/test/provider/termination.test.ts @@ -9,24 +9,35 @@ 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 } }, + { 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; npm?: string } = {}, + 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), { - preconnect: fetch.preconnect, - }) + const fetcher = Object.assign( + async () => response(options.reason, options.message, options.request, options.googleRequest), + { + preconnect: fetch.preconnect, + }, + ) const model = options.vertex ? createVertex({ project: "fixture", @@ -91,6 +102,29 @@ describe("provider termination diagnostics", () => { 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", diff --git a/packages/sdk/src/v2/gen/types.gen.ts b/packages/sdk/src/v2/gen/types.gen.ts index dc8835b..8a91f5c 100644 --- a/packages/sdk/src/v2/gen/types.gen.ts +++ b/packages/sdk/src/v2/gen/types.gen.ts @@ -10,26 +10,7 @@ export type StepFinishPart = { messageID: string type: "step-finish" reason: string - termination?: { - 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 - } - } + termination?: ProviderTermination snapshot?: string cost: number tokens: {