fix(ir): accept OpenAI role=developer at entry surface, normalize to system (#65)

* fix(ir): accept OpenAI role=developer at entry surface, normalize to system

Hermes Agent (v0.13+) and other modern openai-completions clients (Cline,
Continue.dev) default to role=developer for the high-priority instruction
slot when the model id matches OpenAI o1/o3+ reasoning family. OLP IR's
validator rejected this with 400 IR validation failed: role must be one
of system|user|assistant|tool, got "developer".

Reproduced 2026-05-27 on PI230 Hermes v0.14.0 trying olp-codex/gpt-5.5
through PI231 OLP. olp-claude (Anthropic models) was unaffected because
Hermes sends system role for Claude models (no developer-role concept
on Anthropic side).

Fix: extend openai-to-ir.mjs normalizeRole to map developer to system at
the entry boundary. The IR canonical-four-roles invariant is preserved;
every provider plugin's role-handling stays unchanged.

Same pattern as the existing function-to-tool normalization that already
lives in normalizeRole (function role was OpenAI-deprecated). Normalize-at-
entry centralizes role-spec-evolution handling in one file rather than
bloating the IR schema and forcing every provider plugin to handle each
new role.

Why not add developer to VALID_ROLES: it would require branches in three
provider plugins (anthropic, codex, mistral) all mapping to system-style
annotation anyway, plus wider IR surface for any future OpenAI role
addition.

Tests: two new pin tests in Suite IR translation:
- developer role to system translation
- mixed-role array including developer validates cleanly

768 to 770 tests, 0 fail. Verified Hermes via olp-codex no longer hits the
IR rejection error after this fix (smoke run from PI230).

ADR 0003 Amendment 3 documents the rationale + future-role policy
(normalize-at-entry unless a role genuinely conveys
provider-distinguishable semantics).

Authority: OpenAI Responses API spec developer-role + Hermes Agent
v0.14.0 reproduction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test+docs: PR #65 fold-in reviewer N2 + N3

N2 — Negative control test added: unknown role (e.g. "admin") still raises
BadRequestError. Pins that the developer-to-system normalization is the
only entry-surface escape hatch; any future widening of normalizeRole that
returns role as-is for unknown inputs will be caught by this test.

N3 — ADR 0003 Amendment 3 gains a "Cache-key impact" bullet documenting
that a developer-form and system-form request with otherwise-identical
content now share the same IR and thus the same cache key (per ADR 0005).
This is by design and matches OpenAI's own backward-compat semantics; the
note exists so a future debug session investigating "why does my developer
request hit a cache entry from an old system request" finds the answer.

Test count: 770 to 771, all pass.

Skipped reviewer N1 (URL precision) — Amendment already cites multiple
authorities including the Hermes/Cline tracking convention and live
reproduction transcript; single-URL precision is over-spec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-27 18:55:11 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.7
parent e2f41eb60e
commit 40f9453d88
3 changed files with 73 additions and 2 deletions
+39
View File
@@ -319,6 +319,45 @@ describe('openAIToIR translation', () => {
assert.equal(ir.messages[0].name, 'my_fn');
});
it('maps role=developer to role=system (OpenAI o1/o3+ reasoning shape)', () => {
const ir = openAIToIR({
model: 'm',
messages: [{ role: 'developer', content: 'High-priority instructions.' }],
});
assert.equal(ir.messages[0].role, 'system', 'developer should normalize to system');
assert.equal(ir.messages[0].content, 'High-priority instructions.');
});
it('mixed roles including developer all validate cleanly through IR', () => {
const ir = openAIToIR({
model: 'm',
messages: [
{ role: 'developer', content: 'Be terse.' },
{ role: 'user', content: 'Hi.' },
{ role: 'assistant', content: 'Hello.' },
],
});
assert.equal(ir.messages.length, 3);
assert.equal(ir.messages[0].role, 'system');
assert.equal(ir.messages[1].role, 'user');
assert.equal(ir.messages[2].role, 'assistant');
});
it('rejects an unknown role at entry — normalize didn\'t accidentally widen allow-list', () => {
// Negative control for Amendment 3 — without this pin, a future addition
// to normalizeRole that returns `role` as-is for unknown inputs would silently
// widen the IR role allow-list. Confirms developer→system mapping is the only
// entry-surface escape hatch.
assert.throws(
() => openAIToIR({
model: 'm',
messages: [{ role: 'admin', content: 'I am god.' }],
}),
err => err instanceof BadRequestError && /role must be one of/.test(err.message),
'unknown role should still produce BadRequestError'
);
});
it('translates request with tools', () => {
const ir = openAIToIR({
model: 'm',