Skip to content

Commit a77829f

Browse files
authored
fix: explain invalid_encrypted_content errors after a provider change (#668)
## Result When a thread continues on another provider, the client now shows what went wrong instead of the raw provider JSON: > The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread. Before, Air showed the whole gateway error body: `{"error":{"message":"Provider returned error","code":400,"metadata":{"raw":"{\n \"error\": {...\"code\": \"invalid_encrypted_content\"...`. ## Cause Codex sends requests with `store: false` and resends the encrypted reasoning of earlier turns with every request. Only the provider (and account) that encrypted it can read it. After `providers/set` moves a thread to another route, the new route rejects the history with `invalid_encrypted_content`, and a retry fails the same way. This is Codex behavior, not an adapter bug: `codex exec resume <id> -c model_provider=<other>` sends the other provider's encrypted reasoning too. Codex has no option to drop it, so the adapter only makes the error readable and does not rewrite the history. ## Changes - `readableServiceErrorMessage` recognizes `invalid_encrypted_content` in three forms: the plain provider error `{error: {code}}`, the Codex service envelope `{type: "error", status, error}`, and a gateway error that carries it in `metadata.provider_error_code` or in the JSON of `metadata.raw`. - Other error text stays unchanged. - The function is already used for every path a provider error reaches the client, so ACP v1 (agent message and Air typed failure) and ACP v2 (agent message and idle error) all get the message. ## Verification - New unit tests in `ServiceErrorMessage.test.ts`, and snapshot tests for ACP v1 (legacy text and typed failure) and ACP v2. All 7 fail without the change. - `npm run typecheck` passes. `npm test`: 1877 passed, 36 skipped. - End to end with real Codex and two local OpenAI-compatible gateways on ACP v1 and v2: two prompts on gateway A, `providers/set` to gateway B, which returns the gateway error above; the client gets the readable message.
1 parent 0ba94f5 commit a77829f

8 files changed

Lines changed: 265 additions & 7 deletions

‎src/ServiceErrorMessage.ts‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ const SERVICE_ERROR_TYPES = new Set([
1010
"overloaded_error",
1111
]);
1212

13-
/** Reads the message of a known service error envelope. Other text stays unchanged. */
13+
const INVALID_ENCRYPTED_CONTENT_CODE = "invalid_encrypted_content";
14+
15+
// Codex resends the encrypted reasoning of earlier turns, and only the provider that encrypted it can read it.
16+
const INVALID_ENCRYPTED_CONTENT_MESSAGE = "The current provider can't read this thread's earlier reasoning, "
17+
+ "which another provider or account encrypted. This happens after the provider changes in a thread. "
18+
+ "Switch back to the previous provider or start a new thread.";
19+
20+
/**
21+
* Reads the message of a known service error envelope, or adds a human-readable explanation.
22+
*/
1423
export function readableServiceErrorMessage(text: string): string {
15-
let value: unknown;
16-
try {
17-
value = JSON.parse(text);
18-
} catch {
19-
return text;
20-
}
24+
const value = parseJson(text);
25+
if (value === undefined) return text;
26+
if (isInvalidEncryptedContentError(value)) return INVALID_ENCRYPTED_CONTENT_MESSAGE;
2127
if (!isObject(value)
2228
|| Object.keys(value).some(key => !["type", "status", "error"].includes(key))
2329
|| value["type"] !== "error"
@@ -34,6 +40,25 @@ export function readableServiceErrorMessage(text: string): string {
3440
return error["message"];
3541
}
3642

43+
/** A provider error `{error: {code}}`, or a gateway error that carries it in `metadata`. */
44+
function isInvalidEncryptedContentError(value: unknown): boolean {
45+
if (!isObject(value) || !isObject(value["error"])) return false;
46+
const error = value["error"];
47+
if (error["code"] === INVALID_ENCRYPTED_CONTENT_CODE) return true;
48+
const metadata = error["metadata"];
49+
if (!isObject(metadata)) return false;
50+
return metadata["provider_error_code"] === INVALID_ENCRYPTED_CONTENT_CODE
51+
|| typeof metadata["raw"] === "string" && isInvalidEncryptedContentError(parseJson(metadata["raw"]));
52+
}
53+
54+
function parseJson(text: string): unknown {
55+
try {
56+
return JSON.parse(text);
57+
} catch {
58+
return undefined;
59+
}
60+
}
61+
3762
function isObject(value: unknown): value is Record<string, unknown> {
3863
return value !== null && typeof value === "object" && !Array.isArray(value);
3964
}

‎src/__tests__/CodexACPAgent/auth-error-events.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ const modelRefusalEnvelope = JSON.stringify({
8181
type: "error", status: 400, error: {type: "invalid_request_error", message: modelRefusalMessage},
8282
});
8383

84+
// The 400 that an LLM gateway returns when the provider can't decrypt reasoning from an earlier turn.
85+
const invalidEncryptedContentGatewayError = JSON.stringify({
86+
error: {
87+
message: "Provider returned error",
88+
code: 400,
89+
metadata: {
90+
raw: JSON.stringify({error: {
91+
message: "The encrypted content for item rs_1 could not be verified. Reason: Encrypted content could not be decrypted or parsed.",
92+
type: "invalid_request_error", param: null, code: "invalid_encrypted_content",
93+
}}, null, 2),
94+
provider_name: "Azure", is_byok: false, provider_error_code: "invalid_encrypted_content",
95+
},
96+
},
97+
user_id: "org_1",
98+
});
99+
84100
describe("CodexEventHandler - auth error events", () => {
85101
it.each([
86102
{name: "terminal", retry: false, turnId: "turn-id"},
@@ -139,6 +155,20 @@ describe("CodexEventHandler - auth error events", () => {
139155
expect(result).toMatchObject({data: {message: modelRefusalEnvelope, codexErrorInfo: "usageLimitExceeded"}});
140156
});
141157

158+
it.each([
159+
{name: "legacy error text", capabilities: undefined},
160+
{name: "typed failure", capabilities: typedFailureCapabilities},
161+
])("explains encrypted content from another provider in the $name", async ({capabilities}) => {
162+
const {result, updates} = await runPromptWithError(createTestSessionState({
163+
sessionId: "encrypted-content-session", account: {type: "apiKey"},
164+
}), {
165+
message: invalidEncryptedContentGatewayError, codexErrorInfo: "badRequest", additionalDetails: null, misalignment: null,
166+
}, false, capabilities);
167+
await expect(JSON.stringify({result, updates}, null, 2)).toMatchFileSnapshot(
168+
`data/invalid-encrypted-content-v1-${capabilities ? "typed-failure" : "legacy"}.json`,
169+
);
170+
});
171+
142172
it.each([
143173
"Plain error", "", "{invalid JSON}",
144174
JSON.stringify({type: "error", status: 400, error: {type: "custom_error", message: "custom text"}}),
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"result": {
3+
"stopReason": "end_turn",
4+
"usage": null,
5+
"_meta": {
6+
"quota": {
7+
"token_count": null,
8+
"model_usage": []
9+
}
10+
}
11+
},
12+
"updates": [
13+
{
14+
"sessionUpdate": "agent_message_chunk",
15+
"content": {
16+
"type": "text",
17+
"text": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.\n\n"
18+
}
19+
}
20+
]
21+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"result": {
3+
"stopReason": "end_turn",
4+
"usage": null,
5+
"_meta": {
6+
"quota": {
7+
"token_count": null,
8+
"model_usage": []
9+
},
10+
"jetbrains": {
11+
"air": {
12+
"version": 1,
13+
"sessionFailure": {
14+
"id": "turn-id:error",
15+
"revision": 1,
16+
"category": "request",
17+
"severity": "error",
18+
"title": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.",
19+
"actions": []
20+
}
21+
}
22+
}
23+
}
24+
},
25+
"updates": []
26+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
[
2+
{
3+
"codexRequest": "turn/start",
4+
"params": {
5+
"threadId": "thread-1",
6+
"input": [
7+
{
8+
"type": "text",
9+
"text": "Hello",
10+
"text_elements": []
11+
}
12+
],
13+
"approvalPolicy": "on-request",
14+
"approvalsReviewer": "auto_review",
15+
"sandboxPolicy": {
16+
"type": "workspaceWrite",
17+
"writableRoots": [],
18+
"networkAccess": false,
19+
"excludeTmpdirEnvVar": false,
20+
"excludeSlashTmp": false
21+
},
22+
"summary": "auto",
23+
"effort": "medium",
24+
"model": "gpt-5",
25+
"serviceTier": null,
26+
"clientUserMessageId": "<messageId>"
27+
}
28+
},
29+
{
30+
"codexResponse": "turn/start"
31+
},
32+
{
33+
"sessionUpdate": {
34+
"state": "running",
35+
"sessionUpdate": "state_update"
36+
}
37+
},
38+
{
39+
"codexNotification": "turn/started"
40+
},
41+
{
42+
"codexNotification": "item/completed"
43+
},
44+
{
45+
"sessionUpdate": {
46+
"messageId": "<messageId>",
47+
"content": {
48+
"text": "Hello",
49+
"type": "text"
50+
},
51+
"sessionUpdate": "user_message_chunk"
52+
}
53+
},
54+
{
55+
"promptResponse": {
56+
"messageId": "<messageId>"
57+
}
58+
},
59+
{
60+
"codexNotification": "error"
61+
},
62+
{
63+
"codexNotification": "turn/completed"
64+
},
65+
{
66+
"sessionUpdate": {
67+
"messageId": "<agentMessageId>",
68+
"content": {
69+
"text": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.\n\n",
70+
"type": "text"
71+
},
72+
"sessionUpdate": "agent_message_chunk"
73+
}
74+
},
75+
{
76+
"sessionUpdate": {
77+
"title": "Hello",
78+
"sessionUpdate": "session_info_update"
79+
}
80+
},
81+
{
82+
"sessionUpdate": {
83+
"error": {
84+
"code": -32603,
85+
"message": "The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread."
86+
},
87+
"stopReason": "error",
88+
"_meta": {
89+
"quota": {
90+
"token_count": null,
91+
"model_usage": []
92+
}
93+
},
94+
"state": "idle",
95+
"sessionUpdate": "state_update"
96+
}
97+
}
98+
]

‎src/__tests__/CodexACPAgent/prompt-v2.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,31 @@ describe('session/prompt over ACP v2', () => {
184184
await expect(dump(results)).toMatchFileSnapshot('data/prompt-v2-turn-error-after-insertion.json');
185185
});
186186

187+
it('explains encrypted content from another provider instead of the raw gateway error', async () => {
188+
const client = await connectSession();
189+
closeClient = () => client.connection.close();
190+
191+
const start = client.transcript.length;
192+
const messageId = await insertPrompt(client, 0);
193+
client.emit(turnError("badRequest", JSON.stringify({
194+
error: {
195+
message: "Provider returned error",
196+
code: 400,
197+
metadata: {
198+
raw: JSON.stringify({error: {message: "The encrypted content for item rs_1 could not be verified.", code: "invalid_encrypted_content"}}),
199+
provider_name: "Azure",
200+
provider_error_code: "invalid_encrypted_content",
201+
},
202+
},
203+
})));
204+
client.emit(turnFinished("failed"));
205+
await client.promptRunFinished();
206+
await settle();
207+
208+
await expect(dump(stableTranscript(client.transcript.slice(start), messageId)))
209+
.toMatchFileSnapshot('data/prompt-v2-invalid-encrypted-content.json');
210+
});
211+
187212
it('puts a typed terminal failure on the idle _meta, not in a session update', async () => {
188213
const client = await connectSession(2, {clientCapabilities: typedFailureCapabilities});
189214
closeClient = () => client.connection.close();

‎src/__tests__/ServiceErrorMessage.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,38 @@ describe("service error messages", () => {
1818
},
1919
);
2020

21+
describe("encrypted content from another provider", () => {
22+
const providerError = {
23+
message: "The encrypted content for item rs_1 could not be verified. Reason: Encrypted content could not be decrypted or parsed.",
24+
type: "invalid_request_error",
25+
param: null,
26+
code: "invalid_encrypted_content",
27+
};
28+
29+
it.each([
30+
["the provider error", {error: providerError}],
31+
["the Codex service envelope", {type: "error", status: 400, error: providerError}],
32+
["a gateway error with the raw provider error", {
33+
error: {message: "Provider returned error", code: 400, metadata: {raw: JSON.stringify({error: providerError}, null, 2)}},
34+
user_id: "org_1",
35+
}],
36+
["a gateway error with the provider error code", {
37+
error: {message: "Provider returned error", code: 400, metadata: {provider_name: "Azure", is_byok: false, provider_error_code: "invalid_encrypted_content"}},
38+
}],
39+
])("explains %s", async (_name, error) => {
40+
await expect(readableServiceErrorMessage(JSON.stringify(error)))
41+
.toMatchFileSnapshot("data/service-error-invalid-encrypted-content.txt");
42+
});
43+
44+
it.each([
45+
JSON.stringify({error: {...providerError, code: "other_code"}}),
46+
JSON.stringify({error: {message: "Provider returned error", metadata: {raw: "{not json}"}}}),
47+
JSON.stringify({error: {message: "Provider returned error", metadata: {raw: JSON.stringify({error: {code: "other_code"}})}}}),
48+
])("keeps other provider errors verbatim: %s", text => {
49+
expect(readableServiceErrorMessage(text)).toBe(text);
50+
});
51+
});
52+
2153
it.each([
2254
"Plain error text", "", "{not json}", "null", "[]", '"json string"',
2355
JSON.stringify({error: {message}}),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The current provider can't read this thread's earlier reasoning, which another provider or account encrypted. This happens after the provider changes in a thread. Switch back to the previous provider or start a new thread.

0 commit comments

Comments
 (0)