Skip to content

Commit b918873

Browse files
committed
fix(anthropic): strip Codex's Responses-only encrypted marker from tool input_schema (#85)
1 parent e335e84 commit b918873

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

src/adapters/anthropic.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,9 +487,37 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
487487
return converted;
488488
}
489489

490+
// Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
491+
// collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
492+
// annotation for the ChatGPT backend only. Anthropic input_schema is strict
493+
// JSON Schema; strip the marker defensively everywhere it can appear as a
494+
// schema keyword, while preserving properties literally named "encrypted".
495+
const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
496+
const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);
497+
498+
function stripEncryptedMarker(node: unknown, inNameBag = false): unknown {
499+
if (Array.isArray(node)) return node.map(item => stripEncryptedMarker(item));
500+
if (!node || typeof node !== "object") return node;
501+
502+
const out: Record<string, unknown> = {};
503+
504+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
505+
if (inNameBag) {
506+
out[key] = stripEncryptedMarker(value);
507+
} else if (key !== "encrypted") {
508+
out[key] = ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)
509+
? value
510+
: stripEncryptedMarker(value, ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key));
511+
}
512+
}
513+
514+
return out;
515+
}
516+
490517
function normalizeAnthropicInputSchema(schema: unknown): Record<string, unknown> {
491-
const obj = schema && typeof schema === "object" && !Array.isArray(schema)
492-
? schema as Record<string, unknown>
518+
const stripped = stripEncryptedMarker(schema);
519+
const obj = stripped && typeof stripped === "object" && !Array.isArray(stripped)
520+
? stripped as Record<string, unknown>
493521
: {};
494522
// Anthropic rejects root-level missing type and oneOf/anyOf/allOf in input_schema.
495523
// Normalize the root only: ensure type:"object" + properties, flatten root composition

tests/anthropic-tool-schema.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,79 @@ describe("anthropic tool input_schema normalization", () => {
106106
},
107107
});
108108
});
109+
110+
test("strips Codex's Responses-only encrypted marker from collaboration schemas", () => {
111+
const inputSchema = toolSchema({
112+
type: "object",
113+
properties: {
114+
message: { type: "string", encrypted: true },
115+
},
116+
required: ["message"],
117+
});
118+
119+
expect(JSON.stringify(inputSchema)).not.toContain('"encrypted"');
120+
expect(inputSchema.properties).toEqual({ message: { type: "string" } });
121+
expect(inputSchema.required).toEqual(["message"]);
122+
});
123+
124+
test("preserves a property literally named encrypted", () => {
125+
expect(toolSchema({
126+
type: "object",
127+
properties: { encrypted: { type: "boolean" } },
128+
}).properties).toEqual({ encrypted: { type: "boolean" } });
129+
});
130+
131+
test("strips the marker under items, nested properties, and flattened root anyOf branches", () => {
132+
const inputSchema = toolSchema({
133+
type: "object",
134+
properties: {
135+
list: { type: "array", items: { type: "string", encrypted: true } },
136+
outer: {
137+
type: "object",
138+
properties: { nested: { type: "number", encrypted: true } },
139+
},
140+
},
141+
anyOf: [
142+
{ type: "object", properties: { fromBranch: { type: "boolean", encrypted: true } } },
143+
],
144+
});
145+
146+
const properties = inputSchema.properties as Record<string, Record<string, unknown>>;
147+
expect((properties.list.items as Record<string, unknown>).encrypted).toBeUndefined();
148+
expect(((properties.outer.properties as Record<string, Record<string, unknown>>).nested).encrypted).toBeUndefined();
149+
expect(properties.fromBranch.encrypted).toBeUndefined();
150+
});
151+
152+
test("preserves literal encrypted values, required names, and encrypted definition names", () => {
153+
const inputSchema = toolSchema({
154+
type: "object",
155+
default: { encrypted: true },
156+
required: ["encrypted"],
157+
$defs: { encrypted: { type: "string" } },
158+
properties: {
159+
constant: { const: { encrypted: true } },
160+
choices: { enum: [{ encrypted: true }] },
161+
},
162+
});
163+
164+
expect(inputSchema.default).toEqual({ encrypted: true });
165+
expect(inputSchema.required).toEqual(["encrypted"]);
166+
expect(inputSchema.$defs).toEqual({ encrypted: { type: "string" } });
167+
expect(inputSchema.properties).toEqual({
168+
constant: { const: { encrypted: true } },
169+
choices: { enum: [{ encrypted: true }] },
170+
});
171+
});
172+
173+
test("does not mutate the input schema", () => {
174+
const schema = {
175+
type: "object",
176+
properties: { message: { type: "string", encrypted: true } },
177+
};
178+
const before = structuredClone(schema);
179+
180+
toolSchema(schema);
181+
182+
expect(schema).toEqual(before);
183+
});
109184
});

0 commit comments

Comments
 (0)