diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 510bd110a..e5e00067a 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -112,7 +112,7 @@ import { promoteResponsesToolUseStopReason, type SequentialCutoffSummaryState, } from "./openai-shared"; -import { transformMessages } from "./transform-messages"; +import { redactSensitiveInObject, transformMessages } from "./transform-messages"; export interface OpenAICodexResponsesOptions extends StreamOptions { reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; @@ -3954,13 +3954,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex | Array | undefined; if (historyItems) { - for (const item of historyItems) { + const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array; + for (const item of redactedHistoryItems) { const maybe = item as { type?: string; call_id?: string }; if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") { customCallIds.add(maybe.call_id); } } - messages.push(...historyItems); + messages.push(...redactedHistoryItems); msgIndex += 1; continue; } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 2be20dd81..053bc06b3 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -594,7 +594,11 @@ const streamOpenAIResponsesOnce = ( error instanceof Error && /previous[ _]?response/i.test(error.message) && /zero[ _-]?data[ _-]?retention/i.test(error.message); - if (!zdrRejection && !isOpenAIResponsesStalePreviousResponseError(error)) { + const isPromptBlocked = + error instanceof Error && + ((error as { code?: string }).code === "invalid_prompt" || + /invalid_prompt|Request blocked/i.test(error.message)); + if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) { throw error; } // Server rejected the chain baseline: reset, count the failure (or diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 0751c4ece..ecb6f0d05 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -1,5 +1,14 @@ import { renderDemotedThinking } from "../dialect/demotion"; -import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types"; +import type { + Api, + AssistantMessage, + DeveloperMessage, + Message, + Model, + ToolCall, + ToolResultMessage, + UserMessage, +} from "../types"; import { isDemotedThinking, kDemotedThinking } from "../utils/block-symbols"; const enum ToolCallStatus { @@ -286,6 +295,156 @@ function normalizeAnthropicTargetToolCallId( * - Preserves tool call structure (unlike converting to text summaries) * - Injects synthetic "aborted" tool results */ +const SENSITIVE_TOKEN_RE = + /(? pattern.test(secret)).length >= 2; +} + +export function redactSensitiveCredentials(text: string): string { + return text.replace(SENSITIVE_TOKEN_RE, match => { + if (!hasPlausibleCredentialEntropy(match)) return match; + const lower = match.toLowerCase(); + if (lower.startsWith("gh")) { + return "[github_token_redacted]"; + } + if (lower.startsWith("gl")) { + return "[gitlab_token_redacted]"; + } + if (lower.startsWith("sk-ant-")) { + return "[anthropic_token_redacted]"; + } + if (lower.startsWith("sk")) { + return "[openai_token_redacted]"; + } + return "[token_redacted]"; + }); +} + +export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } { + if (typeof val === "string") { + const redacted = redactSensitiveCredentials(val); + return { result: redacted, changed: redacted !== val }; + } + if (Array.isArray(val)) { + let changed = false; + const result = val.map(item => { + const res = redactSensitiveInObject(item); + if (res.changed) changed = true; + return res.result; + }); + return { result, changed }; + } + if (val !== null && typeof val === "object") { + let changed = false; + const res: Record = {}; + for (const [k, v] of Object.entries(val)) { + const sub = redactSensitiveInObject(v); + if (sub.changed) changed = true; + res[k] = sub.result; + } + return { result: res, changed }; + } + return { result: val, changed: false }; +} + +function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] { + return messages.map((msg): Message => { + if (msg.role === "user" || msg.role === "developer") { + const userMsg = msg as UserMessage | DeveloperMessage; + if (typeof userMsg.content === "string") { + const redacted = redactSensitiveCredentials(userMsg.content); + if (redacted === userMsg.content) return msg; + return { ...userMsg, content: redacted } as Message; + } + const contentArray = userMsg.content; + let changed = false; + const content = contentArray.map((block): UserMessage["content"][number] => { + if (block.type === "text") { + const redacted = redactSensitiveCredentials(block.text); + if (redacted !== block.text) { + changed = true; + return { ...block, text: redacted }; + } + } + return block; + }); + return (changed ? { ...userMsg, content } : userMsg) as Message; + } + + if (msg.role === "toolResult") { + const toolResultMsg = msg as ToolResultMessage; + let changed = false; + const content = toolResultMsg.content.map((block): ToolResultMessage["content"][number] => { + if (block.type === "text") { + const redacted = redactSensitiveCredentials(block.text); + if (redacted !== block.text) { + changed = true; + return { ...block, text: redacted }; + } + } + return block; + }); + return (changed ? { ...toolResultMsg, content } : toolResultMsg) as Message; + } + + if (msg.role === "assistant") { + const assistantMsg = msg as AssistantMessage; + let changed = false; + const content = assistantMsg.content.map((block): AssistantMessage["content"][number] => { + if (block.type === "text") { + const redacted = redactSensitiveCredentials(block.text); + if (redacted !== block.text) { + changed = true; + return { ...block, text: redacted }; + } + } else if (block.type === "thinking") { + const redacted = redactSensitiveCredentials(block.thinking); + if (redacted !== block.thinking) { + changed = true; + return { ...block, thinking: redacted, thinkingSignature: undefined }; + } + } else if (block.type === "toolCall") { + if (block.arguments) { + const { result: redactedArgs, changed: argsChanged } = redactSensitiveInObject(block.arguments); + if (argsChanged) { + changed = true; + const castArgs = + redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs) + ? (redactedArgs as Record) + : undefined; + return { + ...block, + arguments: castArgs, + thoughtSignature: undefined, + } as AssistantMessage["content"][number]; + } + } + } + return block; + }); + return (changed ? { ...assistantMsg, content } : assistantMsg) as Message; + } + + return msg; + }); +} + export function transformMessages( messages: Message[], model: Model, @@ -294,6 +453,10 @@ export function transformMessages( duplicateToolCallIdSuffixPrefix = "_dup", targetCompat: Model["compat"] = model.compat, ): Message[] { + // Redact sensitive credential-like patterns from all outbound messages + // to prevent security block errors from LLM providers (e.g. invalid_prompt). + messages = redactSensitiveCredentialsInMessages(messages); + // Drop assistant `toolCall` blocks with empty/whitespace `id` or `name` // (and their matched `toolResult` messages) before anything else looks at // the history. Replays of these would 400 every provider — see diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index 0445cd3ac..5c8a4bf9d 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -1,5 +1,6 @@ import { $env } from "@oh-my-pi/pi-utils"; import type { ResponseInput, ResponseInputItem } from "./providers/openai-responses-wire"; +import { redactSensitiveCredentials } from "./providers/transform-messages"; import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types"; type OpenAIResponsesReplayItem = ResponseInput[number]; @@ -9,7 +10,9 @@ export { isRecord } from "@oh-my-pi/pi-utils"; export function normalizeSystemPrompts(systemPrompt: readonly string[] | string | undefined | null): string[] { if (systemPrompt === undefined || systemPrompt === null) return []; const prompts = Array.isArray(systemPrompt) ? systemPrompt : typeof systemPrompt === "string" ? [systemPrompt] : []; - return prompts.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.trim().length > 0); + return prompts + .map(prompt => redactSensitiveCredentials(prompt.toWellFormed())) + .filter(prompt => prompt.trim().length > 0); } export function normalizeToolCallId(id: string): string { diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index dd3face59..de55b5e16 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -1205,3 +1205,34 @@ describe("openai-codex concurrent reasoning summaries", () => { expect(text?.text).toBe("Hello"); }); }); + +describe("openai-codex native history redaction", () => { + it("redacts credentials from user provider history before replaying it", () => { + const model = createCodexModel("gpt-5.1-codex"); + const credential = "sk-ABCdef1234567890ABCdef1234567890ABCdef1234567890ABCdef123456"; + const context: Context = { + messages: [ + { + role: "user", + content: "fallback", + timestamp: Date.now(), + providerPayload: { + type: "openaiResponsesHistory", + provider: model.provider, + items: [{ type: "message", role: "user", content: [{ type: "input_text", text: credential }] }], + }, + } as Context["messages"][number], + ], + }; + + const messages = convertCodexResponsesMessages(model, context); + + expect(messages).toEqual([ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "[openai_token_redacted]" }], + }, + ]); + }); +}); diff --git a/packages/ai/test/openai-responses-stateful.test.ts b/packages/ai/test/openai-responses-stateful.test.ts index 8f6fb1b1a..8871f0961 100644 --- a/packages/ai/test/openai-responses-stateful.test.ts +++ b/packages/ai/test/openai-responses-stateful.test.ts @@ -230,6 +230,58 @@ describe("openai-responses stateful chaining", () => { expect(JSON.stringify(sentRequests[2]?.input)).toContain("First question"); expect(JSON.stringify(sentRequests[2]?.input)).toContain("Second question"); }); + it("retries a blocked invalid_prompt previous_response_id with the full transcript", async () => { + const sentRequests: Array> = []; + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as Record; + sentRequests.push(request); + if (typeof request.previous_response_id === "string") { + return new Response( + JSON.stringify({ + error: { + message: "Request blocked.", + type: "invalid_request_error", + code: "invalid_prompt", + }, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return createStatefulSse(`Answer ${sentRequests.length}`, `resp_${sentRequests.length}`); + }) as FetchImpl; + const providerSessionState = new Map(); + const options = { + apiKey: "test-key", + sessionId: "stateful-blocked-session", + providerSessionState, + statefulResponses: true, + reasoning: "low" as const, + fetch: fetchMock, + }; + + const firstUser = { role: "user" as const, content: "First question", timestamp: 1000 }; + const firstResponse = await streamOpenAIResponses( + model, + { systemPrompt, messages: [firstUser] }, + options, + ).result(); + const secondResponse = await streamOpenAIResponses( + model, + { + systemPrompt, + messages: [firstUser, firstResponse, { role: "user", content: "Second question", timestamp: 1001 }], + }, + options, + ).result(); + + expect(secondResponse.stopReason).toBe("stop"); + expect(JSON.stringify(secondResponse.content)).toContain("Answer 3"); + expect(sentRequests).toHaveLength(3); + expect(sentRequests[1]?.previous_response_id).toBe("resp_1"); + expect(sentRequests[2]?.previous_response_id).toBeUndefined(); + expect(JSON.stringify(sentRequests[2]?.input)).toContain("First question"); + expect(JSON.stringify(sentRequests[2]?.input)).toContain("Second question"); + }); it("disables chaining for the session after repeated stale failures and stops forcing store", async () => { const sentRequests: Array> = []; diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts index f09888189..26103a45c 100644 --- a/packages/ai/test/openai-responses-system-prompt.test.ts +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -86,6 +86,16 @@ describe("openai-responses system prompt routing", () => { expect(input.every(m => m.role !== "system")).toBe(true); }); + it("redacts sensitive credentials in instructions", async () => { + const context: Context = { + systemPrompt: ["Token: gho_************************************"], + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }; + const body = await captureRequestBody(gpt4oMiniModel, context); + + expect(body.instructions).toBe("Token: [github_token_redacted]"); + }); + it("omits instructions field when there is no system prompt", async () => { const context: Context = { systemPrompt: undefined, diff --git a/packages/ai/test/transform-messages-redact-sensitive.test.ts b/packages/ai/test/transform-messages-redact-sensitive.test.ts new file mode 100644 index 000000000..e701f628a --- /dev/null +++ b/packages/ai/test/transform-messages-redact-sensitive.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "bun:test"; +import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; +import type { AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +function makeModel(): Model<"openai-responses"> { + return buildModel({ + api: "openai-responses", + name: "GPT Test", + id: "gpt-test", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + contextWindow: 8192, + maxTokens: 2048, + input: ["text"], + reasoning: false, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); +} + +describe("transformMessages redact sensitive credentials", () => { + it("redacts already-masked and real tokens from outbound messages", () => { + const messages: Message[] = [ + { + role: "user", + content: "Token: gho_************************************", + timestamp: Date.now(), + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I found this key: sk-proj-************************************", + }, + { + type: "toolCall", + id: "call_x", + name: "bash", + arguments: { + command: "echo gho_************************************", + }, + }, + ], + api: "openai-responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "call_x", + toolName: "bash", + content: [{ type: "text", text: "Token is ghp_************************************ inside output" }], + isError: false, + timestamp: Date.now(), + }, + ]; + + const transformed = transformMessages(messages, makeModel()); + + // 1. Verify user message is redacted + const userMsg = transformed[0]; + expect(userMsg.role).toBe("user"); + expect(userMsg.content).toBe("Token: [github_token_redacted]"); + + // 2. Verify assistant message text and toolCall arguments are redacted + const assistantMsg = transformed[1]; + expect(assistantMsg.role).toBe("assistant"); + const castAssistantMsg = assistantMsg as AssistantMessage; + const assistantContent = castAssistantMsg.content; + const textBlock = assistantContent[0]; + expect(textBlock.type).toBe("text"); + if (textBlock.type === "text") { + expect(textBlock.text).toBe("I found this key: [openai_token_redacted]"); + } + + const toolCallBlock = assistantContent[1]; + expect(toolCallBlock.type).toBe("toolCall"); + + // 3. Verify toolResult message is redacted + const resultMsg = transformed[2]; + expect(resultMsg.role).toBe("toolResult"); + const toolResultMsg = resultMsg as ToolResultMessage; + const toolResultBlock = toolResultMsg.content[0]; + expect(toolResultBlock.type).toBe("text"); + if (toolResultBlock.type === "text") { + expect(toolResultBlock.text).toBe("Token is [github_token_redacted] inside output"); + } + if (toolCallBlock.type === "toolCall") { + const toolCall = toolCallBlock as ToolCall; + const commandArg = toolCall.arguments?.command; + expect(commandArg).toBe("echo [github_token_redacted]"); + } + }); + + it("drops an Anthropic thinking signature when redacting its signed content", () => { + const model = buildModel({ + api: "anthropic-messages", + name: "Claude Test", + id: "claude-test", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + contextWindow: 8192, + maxTokens: 2048, + input: ["text"], + reasoning: true, + compat: { signingEndpoint: true }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + const messages: Message[] = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Use sk-ABCdef1234567890ABCdef1234567890ABCdef1234567890ABCdef123456.", + thinkingSignature: "signed-thinking-bytes", + }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }, + ]; + + const transformed = transformMessages(messages, model); + + expect(transformed[0]).toMatchObject({ role: "assistant", content: [] }); + }); + + it("drops a tool thought signature after redacting its arguments", () => { + const messages: Message[] = [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_signed", + name: "run", + arguments: { token: "sk-ABCdef1234567890ABCdef1234567890ABCdef1234567890ABCdef123456" }, + thoughtSignature: "signed-tool-arguments", + }, + ], + api: "openai-responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + }, + ]; + + const transformed = transformMessages(messages, makeModel()); + const block = (transformed[0] as AssistantMessage).content[0]; + + expect(block).toMatchObject({ + type: "toolCall", + arguments: { token: "[openai_token_redacted]" }, + }); + if (block.type === "toolCall") { + expect(block.thoughtSignature).toBeUndefined(); + } + }); + + it("preserves credential-shaped prose that is not a plausible live token", () => { + const lookalike = "sk-abcdefghijklmnopqrstuvwxyz"; + const transformed = transformMessages( + [{ role: "user", content: `The example key is ${lookalike}.`, timestamp: Date.now() }], + makeModel(), + ); + + expect(transformed[0]).toMatchObject({ role: "user", content: `The example key is ${lookalike}.` }); + }); +});