@gajae-code/ai 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.2] - 2026-07-14
6
+
7
+ ### Fixed
8
+
9
+ - Fixed frequent `Request blocked (code=invalid_prompt)` failures on gpt-5.6 (Sol/Terra/Luna) subagent, default-agent, and compaction turns (ref openai/codex#32028, oh-my-pi#5184). Leaked Harmony control-token markers (e.g. `<|channel|>analysis`) were only neutralized on the replayed-history payload path, so markers in assistant reasoning summaries, live-converted message/tool-output text, and user-authored content reached the OpenAI Responses and OpenAI-codex-responses transports verbatim and wedged the session (the poisoned item was re-sent every turn). Both transports now neutralize reserved control tokens across the entire outgoing `input` array at the request boundary via an idempotent zero-width-space insertion that keeps the text human-readable.
10
+
5
11
  ## [0.10.0] - 2026-07-12
6
12
  ### Fixed
7
13
 
@@ -18,6 +18,31 @@ export declare function normalizeResponsesToolCallId(id: string, itemPrefix?: Re
18
18
  */
19
19
  export declare function truncateResponseItemId(id: string, prefix: string): string;
20
20
  export declare function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>): ResponseInput;
21
+ /**
22
+ * Neutralize leaked OpenAI Harmony / control tokens (`<|channel|>`, `<|message|>`,
23
+ * `<|call|>`, `<|constrain|>`, `<|recipient|>`, `<|content|>`, ...) in replayed
24
+ * history text. A subagent whose tool-call channel degenerates can dump raw
25
+ * control-token scaffolding into its reply text; once that poisoned text lands in
26
+ * history the Codex / Responses endpoint rejects every subsequent request with
27
+ * `Request blocked (code=invalid_prompt)`, permanently wedging the session because
28
+ * the offending item is re-sent on each turn. Insert a zero-width space after `<`
29
+ * so the delimiter can no longer be tokenized as a reserved control token while the
30
+ * text stays human-readable.
31
+ */
32
+ export declare function neutralizeReservedControlTokens(text: string): string;
33
+ /**
34
+ * Neutralize leaked reserved control tokens across every string in an outgoing
35
+ * Responses `input` array. This is the request-boundary complement to the
36
+ * replay-history sanitizer: leaked Harmony markers (`<|channel|>analysis`, ...)
37
+ * can enter the payload from assistant reasoning summaries, live-converted
38
+ * message/tool-output text, or user-authored content — not just replayed
39
+ * history — and every gpt-5.6 request that carries one is rejected with
40
+ * `Request blocked (code=invalid_prompt)`. Walking every string (rather than an
41
+ * item-type allowlist) guarantees no leak source is missed as item shapes
42
+ * evolve; the zero-width-space insertion is idempotent (`<\u200b|` no longer
43
+ * matches `<|`) and keeps the text human-readable.
44
+ */
45
+ export declare function neutralizeResponsesInputControlTokens<T>(items: readonly T[]): T[];
21
46
  export declare function createOpenAIResponsesHistoryPayload(provider: string, items: Array<Record<string, unknown>>, incremental?: boolean): OpenAIResponsesHistoryPayload;
22
47
  export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
23
48
  export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.10.0",
4
+ "version": "0.10.2",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/utils": "0.10.0",
43
+ "@gajae-code/utils": "0.10.2",
44
44
  "openai": "^6.36.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -43,6 +43,7 @@ import {
43
43
  createOpenAIResponsesHistoryPayload,
44
44
  getOpenAIResponsesHistoryItems,
45
45
  getOpenAIResponsesHistoryPayload,
46
+ neutralizeResponsesInputControlTokens,
46
47
  normalizeSystemPrompts,
47
48
  sanitizeOpenAIResponsesHistoryItemsForReplay,
48
49
  } from "../utils";
@@ -633,7 +634,7 @@ async function buildTransformedCodexRequestBody(
633
634
  ): Promise<RequestBody> {
634
635
  const params: RequestBody = {
635
636
  model: model.id,
636
- input: [...convertMessages(model, context)],
637
+ input: neutralizeResponsesInputControlTokens(convertMessages(model, context)),
637
638
  stream: true,
638
639
  prompt_cache_key: normalizeOpenAIResponsesPromptCacheKey(options?.sessionId),
639
640
  };
@@ -33,6 +33,7 @@ import {
33
33
  createOpenAIResponsesHistoryPayload,
34
34
  getOpenAIResponsesHistoryItems,
35
35
  getOpenAIResponsesHistoryPayload,
36
+ neutralizeResponsesInputControlTokens,
36
37
  normalizeSystemPrompts,
37
38
  resolveCacheRetention,
38
39
  sanitizeOpenAIResponsesHistoryItemsForReplay,
@@ -493,7 +494,7 @@ function buildParams(
493
494
  strictResponsesPairing,
494
495
  providerSessionState,
495
496
  );
496
- const messages: ResponseInput = [...conversationMessages];
497
+ const messages: ResponseInput = neutralizeResponsesInputControlTokens(conversationMessages);
497
498
 
498
499
  const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
499
500
  if (isComposerHarnessModel(model.id)) {
package/src/utils.ts CHANGED
@@ -119,22 +119,68 @@ export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record
119
119
  return sanitized ? [sanitized] : [];
120
120
  });
121
121
  }
122
+ const RESERVED_CONTROL_TOKEN_RE = /<\|(?=[A-Za-z0-9_]{1,32}\|>)/g;
123
+ /**
124
+ * Neutralize leaked OpenAI Harmony / control tokens (`<|channel|>`, `<|message|>`,
125
+ * `<|call|>`, `<|constrain|>`, `<|recipient|>`, `<|content|>`, ...) in replayed
126
+ * history text. A subagent whose tool-call channel degenerates can dump raw
127
+ * control-token scaffolding into its reply text; once that poisoned text lands in
128
+ * history the Codex / Responses endpoint rejects every subsequent request with
129
+ * `Request blocked (code=invalid_prompt)`, permanently wedging the session because
130
+ * the offending item is re-sent on each turn. Insert a zero-width space after `<`
131
+ * so the delimiter can no longer be tokenized as a reserved control token while the
132
+ * text stays human-readable.
133
+ */
134
+ export function neutralizeReservedControlTokens(text: string): string {
135
+ if (!text.includes("<|")) return text;
136
+ return text.replace(RESERVED_CONTROL_TOKEN_RE, "<\u200b|");
137
+ }
138
+
139
+ /**
140
+ * Neutralize leaked reserved control tokens across every string in an outgoing
141
+ * Responses `input` array. This is the request-boundary complement to the
142
+ * replay-history sanitizer: leaked Harmony markers (`<|channel|>analysis`, ...)
143
+ * can enter the payload from assistant reasoning summaries, live-converted
144
+ * message/tool-output text, or user-authored content — not just replayed
145
+ * history — and every gpt-5.6 request that carries one is rejected with
146
+ * `Request blocked (code=invalid_prompt)`. Walking every string (rather than an
147
+ * item-type allowlist) guarantees no leak source is missed as item shapes
148
+ * evolve; the zero-width-space insertion is idempotent (`<\u200b|` no longer
149
+ * matches `<|`) and keeps the text human-readable.
150
+ */
151
+ export function neutralizeResponsesInputControlTokens<T>(items: readonly T[]): T[] {
152
+ return items.map(item => deepNeutralizeReservedControlTokens(item) as T);
153
+ }
154
+
155
+ function deepNeutralizeReservedControlTokens(value: unknown): unknown {
156
+ if (typeof value === "string") return neutralizeReservedControlTokens(value);
157
+ if (Array.isArray(value)) return value.map(deepNeutralizeReservedControlTokens);
158
+ if (value && typeof value === "object") {
159
+ const out: Record<string, unknown> = {};
160
+ for (const [key, nested] of Object.entries(value)) {
161
+ out[key] = deepNeutralizeReservedControlTokens(nested);
162
+ }
163
+ return out;
164
+ }
165
+ return value;
166
+ }
167
+
122
168
  function stringifyResponsesStringParamForReplay(value: unknown): string {
123
- if (typeof value === "string") return value.toWellFormed();
169
+ if (typeof value === "string") return neutralizeReservedControlTokens(value.toWellFormed());
124
170
  try {
125
171
  const encoded = JSON.stringify(value);
126
- if (typeof encoded === "string") return encoded.toWellFormed();
172
+ if (typeof encoded === "string") return neutralizeReservedControlTokens(encoded.toWellFormed());
127
173
  } catch {
128
174
  // Fall through to String().
129
175
  }
130
- return String(value ?? "").toWellFormed();
176
+ return neutralizeReservedControlTokens(String(value ?? "").toWellFormed());
131
177
  }
132
178
 
133
179
  function normalizeResponsesMessageTextForReplay(value: unknown): string {
134
- if (typeof value === "string") return value.toWellFormed();
180
+ if (typeof value === "string") return neutralizeReservedControlTokens(value.toWellFormed());
135
181
  if (value && typeof value === "object") {
136
182
  const nestedText = (value as { text?: unknown }).text;
137
- if (typeof nestedText === "string") return nestedText.toWellFormed();
183
+ if (typeof nestedText === "string") return neutralizeReservedControlTokens(nestedText.toWellFormed());
138
184
  }
139
185
  return stringifyResponsesStringParamForReplay(value);
140
186
  }
@@ -163,7 +209,7 @@ function normalizeResponsesImageUrlForReplay(value: unknown): NormalizedResponse
163
209
  }
164
210
 
165
211
  function sanitizeResponsesMessageContentForReplay(content: unknown): unknown {
166
- if (typeof content === "string") return content.toWellFormed();
212
+ if (typeof content === "string") return neutralizeReservedControlTokens(content.toWellFormed());
167
213
  if (!Array.isArray(content)) return content;
168
214
  return content.map(part => {
169
215
  if (!part || typeof part !== "object") return part;
@@ -197,12 +243,11 @@ function sanitizeResponsesStringFieldsForReplay(item: Record<string, unknown>):
197
243
  if (item.type === "custom_tool_call" && "input" in item && typeof item.input !== "string") {
198
244
  item.input = stringifyResponsesStringParamForReplay(item.input);
199
245
  }
200
- if (
201
- (item.type === "function_call_output" || item.type === "custom_tool_call_output") &&
202
- "output" in item &&
203
- typeof item.output !== "string"
204
- ) {
205
- item.output = stringifyResponsesStringParamForReplay(item.output);
246
+ if ((item.type === "function_call_output" || item.type === "custom_tool_call_output") && "output" in item) {
247
+ item.output =
248
+ typeof item.output === "string"
249
+ ? neutralizeReservedControlTokens(item.output.toWellFormed())
250
+ : stringifyResponsesStringParamForReplay(item.output);
206
251
  }
207
252
  }
208
253