@openwop/openwop 1.1.2 → 1.1.3
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/dist/envelope-directive.d.ts +77 -0
- package/dist/envelope-directive.d.ts.map +1 -0
- package/dist/envelope-directive.js +89 -0
- package/dist/envelope-directive.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/dist/parse-refusal.d.ts +114 -0
- package/dist/parse-refusal.d.ts.map +1 -0
- package/dist/parse-refusal.js +216 -0
- package/dist/parse-refusal.js.map +1 -0
- package/package.json +1 -1
- package/src/envelope-directive.ts +110 -0
- package/src/index.ts +21 -0
- package/src/parse-refusal.ts +311 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envelopeDirective — RFC 0030 §A `reasoning`-field prompt directive synthesis.
|
|
3
|
+
*
|
|
4
|
+
* Hosts that advertise `capabilities.envelopes.reasoning.supported: true`
|
|
5
|
+
* with `promptDirective: "advisory"` or `"mandatory"` inject a system-prompt
|
|
6
|
+
* directive instructing the model to populate the OPTIONAL `reasoning` field
|
|
7
|
+
* on envelope payload schemas that carry it. The directive is informational
|
|
8
|
+
* — hosts MUST NOT reject envelopes where `reasoning` is absent regardless
|
|
9
|
+
* of `promptDirective` strength (RFC 0030 §A).
|
|
10
|
+
*
|
|
11
|
+
* The directive fires only when the envelope's `responseSchema` declares a
|
|
12
|
+
* top-level `reasoning` property. Schemas without `reasoning` (e.g.,
|
|
13
|
+
* `schema.response` per RFC 0030 §A) do NOT receive the directive.
|
|
14
|
+
*
|
|
15
|
+
* Honest separation of concerns:
|
|
16
|
+
* - This module decides WHEN to inject (schema has `reasoning`).
|
|
17
|
+
* - The caller decides WHETHER to inject (read `promptDirective` from
|
|
18
|
+
* the host's discovery advertisement).
|
|
19
|
+
* - The model decides whether to ACTUALLY populate the field (the spec
|
|
20
|
+
* forbids rejecting envelopes where `reasoning` is absent).
|
|
21
|
+
*
|
|
22
|
+
* **Operational note on `"mandatory"`** (per RFC 0030 §A 2026-05-21
|
|
23
|
+
* amendment). Strict-output models may honor the mandatory wording
|
|
24
|
+
* literally and refuse mid-emission when reasoning would be vacuous.
|
|
25
|
+
* Hosts SHOULD prefer `"advisory"` unless empirical testing against the
|
|
26
|
+
* active model class confirms `"mandatory"` doesn't trigger refusals.
|
|
27
|
+
*
|
|
28
|
+
* @see RFCS/0030-envelope-reasoning-and-tier-one-subset.md §A
|
|
29
|
+
* @see spec/v1/ai-envelope.md §"Reasoning field (normative)"
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The strength of the host's `reasoning`-directive prompt injection.
|
|
33
|
+
*
|
|
34
|
+
* `"off"` — no directive injected. Caller skips this module entirely.
|
|
35
|
+
* `"advisory"` — directive is suggestive ("populate `reasoning` with your
|
|
36
|
+
* analytical process if the schema permits it"). The
|
|
37
|
+
* spec-recommended default per RFC 0030 §C.
|
|
38
|
+
* `"mandatory"` — directive is firm ("you MUST populate `reasoning` before
|
|
39
|
+
* emitting the structured fields"). Hosts SHOULD prefer
|
|
40
|
+
* `"advisory"` unless model-class-specific testing shows
|
|
41
|
+
* `"mandatory"` is safe.
|
|
42
|
+
*
|
|
43
|
+
* Both `"advisory"` and `"mandatory"` are prompt-injection postures, NOT
|
|
44
|
+
* wire-level refusal contracts — the host accepts envelopes regardless of
|
|
45
|
+
* whether `reasoning` is populated (RFC 0030 §A normative MUST NOT).
|
|
46
|
+
*/
|
|
47
|
+
export type ReasoningDirectiveStrength = 'off' | 'advisory' | 'mandatory';
|
|
48
|
+
/**
|
|
49
|
+
* Build the directive string to append to the system prompt, OR `null` if
|
|
50
|
+
* the schema does not declare a top-level `reasoning` property.
|
|
51
|
+
*
|
|
52
|
+
* Callers append the returned string to the existing system prompt with a
|
|
53
|
+
* separating newline. When `strength === 'off'`, callers SHOULD short-circuit
|
|
54
|
+
* before invoking this helper (returning `null` here is treated as "no
|
|
55
|
+
* applicable schema," not "directive disabled").
|
|
56
|
+
*
|
|
57
|
+
* The helper inspects only the top-level `properties.reasoning` slot.
|
|
58
|
+
* Nested `reasoning` fields (e.g., inside an `anyOf` branch's payload) are
|
|
59
|
+
* not auto-detected — vendor-kind authors who want per-branch directives
|
|
60
|
+
* synthesize their own.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { buildReasoningDirective } from '@openwop/openwop';
|
|
65
|
+
*
|
|
66
|
+
* const directive = buildReasoningDirective(
|
|
67
|
+
* { type: 'object', properties: { reasoning: { type: 'string' }, ... } },
|
|
68
|
+
* 'advisory',
|
|
69
|
+
* );
|
|
70
|
+
* // directive is a string ~80 words; null when schema lacks `reasoning`
|
|
71
|
+
* const systemPrompt = [originalSystemPrompt, schemaHint, directive]
|
|
72
|
+
* .filter((s): s is string => Boolean(s))
|
|
73
|
+
* .join('\n\n');
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export declare function buildReasoningDirective(responseSchema: unknown, strength: ReasoningDirectiveStrength): string | null;
|
|
77
|
+
//# sourceMappingURL=envelope-directive.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"envelope-directive.d.ts","sourceRoot":"","sources":["../src/envelope-directive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,0BAA0B,GAAG,KAAK,GAAG,UAAU,GAAG,WAAW,CAAC;AAE1E;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,uBAAuB,CACrC,cAAc,EAAE,OAAO,EACvB,QAAQ,EAAE,0BAA0B,GACnC,MAAM,GAAG,IAAI,CA6Bf"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envelopeDirective — RFC 0030 §A `reasoning`-field prompt directive synthesis.
|
|
3
|
+
*
|
|
4
|
+
* Hosts that advertise `capabilities.envelopes.reasoning.supported: true`
|
|
5
|
+
* with `promptDirective: "advisory"` or `"mandatory"` inject a system-prompt
|
|
6
|
+
* directive instructing the model to populate the OPTIONAL `reasoning` field
|
|
7
|
+
* on envelope payload schemas that carry it. The directive is informational
|
|
8
|
+
* — hosts MUST NOT reject envelopes where `reasoning` is absent regardless
|
|
9
|
+
* of `promptDirective` strength (RFC 0030 §A).
|
|
10
|
+
*
|
|
11
|
+
* The directive fires only when the envelope's `responseSchema` declares a
|
|
12
|
+
* top-level `reasoning` property. Schemas without `reasoning` (e.g.,
|
|
13
|
+
* `schema.response` per RFC 0030 §A) do NOT receive the directive.
|
|
14
|
+
*
|
|
15
|
+
* Honest separation of concerns:
|
|
16
|
+
* - This module decides WHEN to inject (schema has `reasoning`).
|
|
17
|
+
* - The caller decides WHETHER to inject (read `promptDirective` from
|
|
18
|
+
* the host's discovery advertisement).
|
|
19
|
+
* - The model decides whether to ACTUALLY populate the field (the spec
|
|
20
|
+
* forbids rejecting envelopes where `reasoning` is absent).
|
|
21
|
+
*
|
|
22
|
+
* **Operational note on `"mandatory"`** (per RFC 0030 §A 2026-05-21
|
|
23
|
+
* amendment). Strict-output models may honor the mandatory wording
|
|
24
|
+
* literally and refuse mid-emission when reasoning would be vacuous.
|
|
25
|
+
* Hosts SHOULD prefer `"advisory"` unless empirical testing against the
|
|
26
|
+
* active model class confirms `"mandatory"` doesn't trigger refusals.
|
|
27
|
+
*
|
|
28
|
+
* @see RFCS/0030-envelope-reasoning-and-tier-one-subset.md §A
|
|
29
|
+
* @see spec/v1/ai-envelope.md §"Reasoning field (normative)"
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Build the directive string to append to the system prompt, OR `null` if
|
|
33
|
+
* the schema does not declare a top-level `reasoning` property.
|
|
34
|
+
*
|
|
35
|
+
* Callers append the returned string to the existing system prompt with a
|
|
36
|
+
* separating newline. When `strength === 'off'`, callers SHOULD short-circuit
|
|
37
|
+
* before invoking this helper (returning `null` here is treated as "no
|
|
38
|
+
* applicable schema," not "directive disabled").
|
|
39
|
+
*
|
|
40
|
+
* The helper inspects only the top-level `properties.reasoning` slot.
|
|
41
|
+
* Nested `reasoning` fields (e.g., inside an `anyOf` branch's payload) are
|
|
42
|
+
* not auto-detected — vendor-kind authors who want per-branch directives
|
|
43
|
+
* synthesize their own.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { buildReasoningDirective } from '@openwop/openwop';
|
|
48
|
+
*
|
|
49
|
+
* const directive = buildReasoningDirective(
|
|
50
|
+
* { type: 'object', properties: { reasoning: { type: 'string' }, ... } },
|
|
51
|
+
* 'advisory',
|
|
52
|
+
* );
|
|
53
|
+
* // directive is a string ~80 words; null when schema lacks `reasoning`
|
|
54
|
+
* const systemPrompt = [originalSystemPrompt, schemaHint, directive]
|
|
55
|
+
* .filter((s): s is string => Boolean(s))
|
|
56
|
+
* .join('\n\n');
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function buildReasoningDirective(responseSchema, strength) {
|
|
60
|
+
if (strength === 'off')
|
|
61
|
+
return null;
|
|
62
|
+
if (!responseSchema || typeof responseSchema !== 'object')
|
|
63
|
+
return null;
|
|
64
|
+
const schema = responseSchema;
|
|
65
|
+
const properties = schema.properties;
|
|
66
|
+
if (!properties || typeof properties !== 'object')
|
|
67
|
+
return null;
|
|
68
|
+
const reasoningProp = properties.reasoning;
|
|
69
|
+
if (!reasoningProp || typeof reasoningProp !== 'object')
|
|
70
|
+
return null;
|
|
71
|
+
if (strength === 'mandatory') {
|
|
72
|
+
return [
|
|
73
|
+
'BEFORE emitting the structured fields, populate the `reasoning` property with your analytical',
|
|
74
|
+
'process — explain how you derived each structured field, what assumptions you made, and what',
|
|
75
|
+
'risks or alternative interpretations you considered. The `reasoning` field is REQUIRED in your',
|
|
76
|
+
'output; do not skip it. (Note: the host accepts envelopes where `reasoning` is absent per',
|
|
77
|
+
'RFC 0030 §A, but for this dispatch the host expects it populated.)',
|
|
78
|
+
].join(' ');
|
|
79
|
+
}
|
|
80
|
+
// strength === 'advisory'
|
|
81
|
+
return [
|
|
82
|
+
'If your response schema declares a `reasoning` property, populate it as the first field with',
|
|
83
|
+
'your analytical process — explain how you derived each structured field. Per Tam et al. (arXiv',
|
|
84
|
+
"2408.02442), models constrained to strict JSON output suffer reasoning-quality collapse when",
|
|
85
|
+
'no reasoning slot exists; use this field to think before emitting the structured payload. The',
|
|
86
|
+
'host accepts envelopes where `reasoning` is absent — populate it when it improves clarity.',
|
|
87
|
+
].join(' ');
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=envelope-directive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"envelope-directive.js","sourceRoot":"","sources":["../src/envelope-directive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAoBH;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,uBAAuB,CACrC,cAAuB,EACvB,QAAoC;IAEpC,IAAI,QAAQ,KAAK,KAAK;QAAE,OAAO,IAAI,CAAC;IACpC,IAAI,CAAC,cAAc,IAAI,OAAO,cAAc,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAEvE,MAAM,MAAM,GAAG,cAA0C,CAAC;IAC1D,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;IACrC,IAAI,CAAC,UAAU,IAAI,OAAO,UAAU,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAE/D,MAAM,aAAa,GAAI,UAAsC,CAAC,SAAS,CAAC;IACxE,IAAI,CAAC,aAAa,IAAI,OAAO,aAAa,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAErE,IAAI,QAAQ,KAAK,WAAW,EAAE,CAAC;QAC7B,OAAO;YACL,+FAA+F;YAC/F,8FAA8F;YAC9F,gGAAgG;YAChG,2FAA2F;YAC3F,oEAAoE;SACrE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACd,CAAC;IAED,0BAA0B;IAC1B,OAAO;QACL,8FAA8F;QAC9F,gGAAgG;QAChG,8FAA8F;QAC9F,+FAA+F;QAC/F,4FAA4F;KAC7F,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACd,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -26,4 +26,8 @@ export type { VerifyWebhookSignatureOptions, VerifyWebhookOutcome, } from './web
|
|
|
26
26
|
export { RegistryClient } from './registry-helpers.js';
|
|
27
27
|
export type { RegistryClientOptions, RegistryDiscovery, RegistryIndex, RegistryIndexEntry, RegistryPackMetadata, RegistryVersionManifest, } from './registry-helpers.js';
|
|
28
28
|
export type { AIEnvelope, AIEnvelopeErrorPayload, ClarificationRequestPayload, EnvelopeContract, EnvelopeContractRefusal, EnvelopeContractsCapability, EnvelopeMeta, EnvelopeOutcome, EnvelopeStrictness, PartialInfo, SchemaRequestPayload, SchemaResponsePayload, ValidationDetail, } from './types.js';
|
|
29
|
+
export { buildReasoningDirective } from './envelope-directive.js';
|
|
30
|
+
export type { ReasoningDirectiveStrength } from './envelope-directive.js';
|
|
31
|
+
export { parseRefusal } from './parse-refusal.js';
|
|
32
|
+
export type { RefusalProvider, RefusalSignal } from './parse-refusal.js';
|
|
29
33
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,eAAe,EACf,WAAW,EACX,kBAAkB,EAClB,sBAAsB,EACtB,uBAAuB,EACvB,0BAA0B,EAC1B,eAAe,EACf,gBAAgB,EAChB,kBAAkB,EAClB,+BAA+B,EAC/B,uBAAuB,EACvB,wBAAwB,EACxB,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,EACb,oBAAoB,EACpB,0BAA0B,EAC1B,sBAAsB,EACtB,wBAAwB,EACxB,mBAAmB,EACnB,mBAAmB,EAEnB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,EACnB,UAAU,EACV,SAAS,EACT,cAAc,EACd,cAAc,EACd,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAIzE,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,cAAc,EACd,yBAAyB,GAC1B,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,uBAAuB,EACvB,WAAW,GACZ,MAAM,oBAAoB,CAAC;AAK5B,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,cAAc,GACf,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,YAAY,EACZ,QAAQ,GACT,MAAM,kBAAkB,CAAC;AAM1B,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,GACvB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAMtE,OAAO,EACL,wCAAwC,EACxC,sBAAsB,EACtB,mBAAmB,GACpB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,YAAY,EACV,qBAAqB,EACrB,iBAAiB,EACjB,aAAa,EACb,kBAAkB,EAClB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,uBAAuB,CAAC;AAI/B,YAAY,EACV,UAAU,EACV,sBAAsB,EACtB,2BAA2B,EAC3B,gBAAgB,EAChB,uBAAuB,EACvB,2BAA2B,EAC3B,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,WAAW,EACX,oBAAoB,EACpB,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,YAAY,EACV,kBAAkB,EAClB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,eAAe,EACf,WAAW,EACX,kBAAkB,EAClB,sBAAsB,EACtB,uBAAuB,EACvB,0BAA0B,EAC1B,eAAe,EACf,gBAAgB,EAChB,kBAAkB,EAClB,+BAA+B,EAC/B,uBAAuB,EACvB,wBAAwB,EACxB,gBAAgB,EAChB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,SAAS,EACT,UAAU,EACV,aAAa,EACb,oBAAoB,EACpB,0BAA0B,EAC1B,sBAAsB,EACtB,wBAAwB,EACxB,mBAAmB,EACnB,mBAAmB,EAEnB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,EACnB,UAAU,EACV,SAAS,EACT,cAAc,EACd,cAAc,EACd,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,YAAY,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAIzE,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,cAAc,EACd,yBAAyB,GAC1B,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,uBAAuB,EACvB,WAAW,GACZ,MAAM,oBAAoB,CAAC;AAK5B,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,cAAc,GACf,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,YAAY,EACZ,QAAQ,GACT,MAAM,kBAAkB,CAAC;AAM1B,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,GACvB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAMtE,OAAO,EACL,wCAAwC,EACxC,sBAAsB,EACtB,mBAAmB,GACpB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,6BAA6B,EAC7B,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,YAAY,EACV,qBAAqB,EACrB,iBAAiB,EACjB,aAAa,EACb,kBAAkB,EAClB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,uBAAuB,CAAC;AAI/B,YAAY,EACV,UAAU,EACV,sBAAsB,EACtB,2BAA2B,EAC3B,gBAAgB,EAChB,uBAAuB,EACvB,2BAA2B,EAC3B,YAAY,EACZ,eAAe,EACf,kBAAkB,EAClB,WAAW,EACX,oBAAoB,EACpB,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,YAAY,CAAC;AAQpB,OAAO,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAClE,YAAY,EAAE,0BAA0B,EAAE,MAAM,yBAAyB,CAAC;AAW1E,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -33,4 +33,21 @@ export { DEFAULT_WEBHOOK_FRESHNESS_WINDOW_SECONDS, verifyWebhookSignature, signW
|
|
|
33
33
|
// typed client for the public node-pack registry at packs.openwop.dev
|
|
34
34
|
// per spec/v1/registry-operations.md.
|
|
35
35
|
export { RegistryClient } from './registry-helpers.js';
|
|
36
|
+
// RFC 0030 §A `reasoning` field prompt-directive helper. Hosts that
|
|
37
|
+
// advertise `capabilities.envelopes.reasoning.supported: true` use this
|
|
38
|
+
// to synthesize the system-prompt fragment that instructs the model to
|
|
39
|
+
// populate the OPTIONAL `reasoning` field. See `envelope-directive.ts`
|
|
40
|
+
// for the operational note on `"mandatory"` strength (provider-side
|
|
41
|
+
// refusal risk per the 2026-05-21 RFC 0030 amendment).
|
|
42
|
+
export { buildReasoningDirective } from './envelope-directive.js';
|
|
43
|
+
// RFC 0032 §B.3 + RFC 0033 §D refusal detection helper. Normalizes
|
|
44
|
+
// per-provider safety-stop signals (Anthropic `stop_reason: "refusal"`,
|
|
45
|
+
// OpenAI `message.refusal` + `finish_reason: "content_filter"`, Gemini
|
|
46
|
+
// `finishReason: "SAFETY"` + `promptFeedback.blockReason: "SAFETY"`)
|
|
47
|
+
// to a canonical `{ refusalText, safetyCategory?, provider }` shape.
|
|
48
|
+
// Hosts route the non-null return through `envelope.refusal` emission
|
|
49
|
+
// + the `envelope_refusal` terminal error code (RFC 0033 §F).
|
|
50
|
+
// **SECURITY:** Pass `refusalText` through the BYOK redaction harness
|
|
51
|
+
// BEFORE persistence — this helper does NOT redact.
|
|
52
|
+
export { parseRefusal } from './parse-refusal.js';
|
|
36
53
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAoDtC,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAGxC,2EAA2E;AAC3E,6DAA6D;AAC7D,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,cAAc,EACd,yBAAyB,GAC1B,MAAM,oBAAoB,CAAC;AAM5B,yEAAyE;AACzE,yEAAyE;AACzE,oBAAoB;AACpB,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,cAAc,GACf,MAAM,kBAAkB,CAAC;AAS1B,iDAAiD;AACjD,sEAAsE;AACtE,mEAAmE;AACnE,uDAAuD;AACvD,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,GACvB,MAAM,uBAAuB,CAAC;AAG/B,sEAAsE;AACtE,4DAA4D;AAC5D,sEAAsE;AACtE,uDAAuD;AACvD,OAAO,EACL,wCAAwC,EACxC,sBAAsB,EACtB,mBAAmB,GACpB,MAAM,sBAAsB,CAAC;AAM9B,uEAAuE;AACvE,sEAAsE;AACtE,sCAAsC;AACtC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAoDtC,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAGxC,2EAA2E;AAC3E,6DAA6D;AAC7D,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,cAAc,EACd,yBAAyB,GAC1B,MAAM,oBAAoB,CAAC;AAM5B,yEAAyE;AACzE,yEAAyE;AACzE,oBAAoB;AACpB,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,cAAc,GACf,MAAM,kBAAkB,CAAC;AAS1B,iDAAiD;AACjD,sEAAsE;AACtE,mEAAmE;AACnE,uDAAuD;AACvD,OAAO,EACL,4BAA4B,EAC5B,sBAAsB,GACvB,MAAM,uBAAuB,CAAC;AAG/B,sEAAsE;AACtE,4DAA4D;AAC5D,sEAAsE;AACtE,uDAAuD;AACvD,OAAO,EACL,wCAAwC,EACxC,sBAAsB,EACtB,mBAAmB,GACpB,MAAM,sBAAsB,CAAC;AAM9B,uEAAuE;AACvE,sEAAsE;AACtE,sCAAsC;AACtC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AA4BvD,oEAAoE;AACpE,wEAAwE;AACxE,uEAAuE;AACvE,uEAAuE;AACvE,oEAAoE;AACpE,uDAAuD;AACvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAGlE,mEAAmE;AACnE,wEAAwE;AACxE,uEAAuE;AACvE,qEAAqE;AACrE,qEAAqE;AACrE,sEAAsE;AACtE,8DAA8D;AAC9D,sEAAsE;AACtE,oDAAoD;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseRefusal — normalize per-provider LLM safety-stop signals to the
|
|
3
|
+
* canonical RFC 0032 §B.3 refusal shape.
|
|
4
|
+
*
|
|
5
|
+
* The three Tier-1 vendors (Anthropic / OpenAI / Google Gemini) surface
|
|
6
|
+
* refusals through different fields:
|
|
7
|
+
*
|
|
8
|
+
* - **Anthropic Messages API**: `stop_reason: "refusal"` (their 2025
|
|
9
|
+
* release) OR `stop_reason: "end_turn"` accompanied by safety-stop
|
|
10
|
+
* markers in the content. Refusal text MAY be inline in the
|
|
11
|
+
* `content[]` array's text blocks.
|
|
12
|
+
* - **OpenAI Chat Completions**: `choices[0].finish_reason:
|
|
13
|
+
* "content_filter"` OR `choices[0].message.refusal: <string>` (the
|
|
14
|
+
* refusal-text field added in their structured-output release).
|
|
15
|
+
* - **Google Gemini**: `candidates[0].finishReason: "SAFETY"` OR
|
|
16
|
+
* `promptFeedback.blockReason: "SAFETY"` (input-side block).
|
|
17
|
+
*
|
|
18
|
+
* Without normalization, every host re-implements this detection. This
|
|
19
|
+
* helper consolidates the per-vendor shape-detection and returns a
|
|
20
|
+
* canonical `RefusalSignal | null` — null when the response is a
|
|
21
|
+
* normal completion (no refusal detected).
|
|
22
|
+
*
|
|
23
|
+
* Per RFC 0032 §B.3 + RFC 0033 §D, hosts MUST NOT retry on refusal
|
|
24
|
+
* (circumvention concern). Callers route the non-null return through
|
|
25
|
+
* `envelope.refusal` emission + the `envelope_refusal` terminal error
|
|
26
|
+
* code (per RFC 0033 §F).
|
|
27
|
+
*
|
|
28
|
+
* Per SECURITY/invariants.yaml §envelope-refusal-no-prompt-leak,
|
|
29
|
+
* `refusalText` MUST be passed through the host's BYOK redaction
|
|
30
|
+
* harness BEFORE persistence — this helper does NOT redact; the
|
|
31
|
+
* caller is responsible for SR-1 carry-forward.
|
|
32
|
+
*
|
|
33
|
+
* @see RFCS/0032-envelope-reliability-events.md §B.3
|
|
34
|
+
* @see RFCS/0033-envelope-completion-contract.md §D + §F
|
|
35
|
+
* @see SECURITY/invariants.yaml §envelope-refusal-no-prompt-leak
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Provider identifier for the matched shape. `"unknown"` is reserved
|
|
39
|
+
* for refusals matched on heuristic signals without a definitive
|
|
40
|
+
* vendor-shape match.
|
|
41
|
+
*/
|
|
42
|
+
export type RefusalProvider = 'anthropic' | 'openai' | 'google' | 'unknown';
|
|
43
|
+
/**
|
|
44
|
+
* Canonical refusal signal. All three Tier-1 vendor shapes normalize
|
|
45
|
+
* to this form. `null` from `parseRefusal()` means "no refusal detected"
|
|
46
|
+
* (a normal completion); a non-null `RefusalSignal` means the caller
|
|
47
|
+
* SHOULD route through the RFC 0032 §B.3 refusal-emission path.
|
|
48
|
+
*/
|
|
49
|
+
export interface RefusalSignal {
|
|
50
|
+
/**
|
|
51
|
+
* The provider's refusal text, when surfaced. MAY be `null` even for
|
|
52
|
+
* detected refusals — Anthropic + Gemini frequently refuse without
|
|
53
|
+
* inline text (the safety filter is opaque to the model). OpenAI's
|
|
54
|
+
* `message.refusal` field is the most consistent source of human-
|
|
55
|
+
* readable refusal text.
|
|
56
|
+
*
|
|
57
|
+
* **SECURITY:** When non-null, `refusalText` MAY contain prompt
|
|
58
|
+
* content that triggered the safety filter. Callers MUST pass it
|
|
59
|
+
* through their BYOK + prompt-content redaction harness BEFORE
|
|
60
|
+
* persistence (per `envelope-refusal-no-prompt-leak` SECURITY
|
|
61
|
+
* invariant).
|
|
62
|
+
*/
|
|
63
|
+
refusalText: string | null;
|
|
64
|
+
/**
|
|
65
|
+
* Provider-specific safety-category identifier, when surfaced.
|
|
66
|
+
* Examples: Gemini's safety-rating categories
|
|
67
|
+
* (`HARM_CATEGORY_HARASSMENT`, etc.), OpenAI's content-filter category,
|
|
68
|
+
* Anthropic's policy-violation tag. Hosts MAY echo this on the
|
|
69
|
+
* `envelope.refusal.safetyCategory` event field for downstream
|
|
70
|
+
* observability.
|
|
71
|
+
*/
|
|
72
|
+
safetyCategory?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Which provider's shape was matched. Useful for debugging
|
|
75
|
+
* cross-host integration + for hosts that want to route refusals
|
|
76
|
+
* differently per vendor (e.g., different operator notifications).
|
|
77
|
+
*/
|
|
78
|
+
provider: RefusalProvider;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse a provider response into a canonical refusal signal.
|
|
82
|
+
*
|
|
83
|
+
* Returns `null` when no refusal is detected (normal completion).
|
|
84
|
+
* Returns a `RefusalSignal` when the response matches one of the
|
|
85
|
+
* three Tier-1 vendors' safety-stop shapes.
|
|
86
|
+
*
|
|
87
|
+
* Detection order: OpenAI → Anthropic → Gemini. Each detector inspects
|
|
88
|
+
* a distinctive top-level field, so cross-vendor false-positives are
|
|
89
|
+
* unlikely. A response that doesn't match any vendor shape returns
|
|
90
|
+
* `null` (hosts that route through novel providers add their own
|
|
91
|
+
* detector + fall back to this for the three known ones).
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* import { parseRefusal } from '@openwop/openwop';
|
|
96
|
+
*
|
|
97
|
+
* const response = await callOpenAI({...});
|
|
98
|
+
* const refusal = parseRefusal(response);
|
|
99
|
+
* if (refusal) {
|
|
100
|
+
* // Route through envelope.refusal emission + envelope_refusal error code.
|
|
101
|
+
* // REMEMBER to redact refusalText through the BYOK harness before
|
|
102
|
+
* // persistence (SECURITY invariant envelope-refusal-no-prompt-leak).
|
|
103
|
+
* await emitEnvelopeRefusal({
|
|
104
|
+
* refusalText: redactBYOK(refusal.refusalText),
|
|
105
|
+
* safetyCategory: refusal.safetyCategory,
|
|
106
|
+
* provider: refusal.provider,
|
|
107
|
+
* });
|
|
108
|
+
* throw new EnvelopeRefusalError(...);
|
|
109
|
+
* }
|
|
110
|
+
* // ...normal-completion handling...
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export declare function parseRefusal(providerResponse: unknown): RefusalSignal | null;
|
|
114
|
+
//# sourceMappingURL=parse-refusal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-refusal.d.ts","sourceRoot":"","sources":["../src/parse-refusal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE5E;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;;;;;;;;OAYG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAE3B;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;OAIG;IACH,QAAQ,EAAE,eAAe,CAAC;CAC3B;AA6LD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,YAAY,CAAC,gBAAgB,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CAM5E"}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseRefusal — normalize per-provider LLM safety-stop signals to the
|
|
3
|
+
* canonical RFC 0032 §B.3 refusal shape.
|
|
4
|
+
*
|
|
5
|
+
* The three Tier-1 vendors (Anthropic / OpenAI / Google Gemini) surface
|
|
6
|
+
* refusals through different fields:
|
|
7
|
+
*
|
|
8
|
+
* - **Anthropic Messages API**: `stop_reason: "refusal"` (their 2025
|
|
9
|
+
* release) OR `stop_reason: "end_turn"` accompanied by safety-stop
|
|
10
|
+
* markers in the content. Refusal text MAY be inline in the
|
|
11
|
+
* `content[]` array's text blocks.
|
|
12
|
+
* - **OpenAI Chat Completions**: `choices[0].finish_reason:
|
|
13
|
+
* "content_filter"` OR `choices[0].message.refusal: <string>` (the
|
|
14
|
+
* refusal-text field added in their structured-output release).
|
|
15
|
+
* - **Google Gemini**: `candidates[0].finishReason: "SAFETY"` OR
|
|
16
|
+
* `promptFeedback.blockReason: "SAFETY"` (input-side block).
|
|
17
|
+
*
|
|
18
|
+
* Without normalization, every host re-implements this detection. This
|
|
19
|
+
* helper consolidates the per-vendor shape-detection and returns a
|
|
20
|
+
* canonical `RefusalSignal | null` — null when the response is a
|
|
21
|
+
* normal completion (no refusal detected).
|
|
22
|
+
*
|
|
23
|
+
* Per RFC 0032 §B.3 + RFC 0033 §D, hosts MUST NOT retry on refusal
|
|
24
|
+
* (circumvention concern). Callers route the non-null return through
|
|
25
|
+
* `envelope.refusal` emission + the `envelope_refusal` terminal error
|
|
26
|
+
* code (per RFC 0033 §F).
|
|
27
|
+
*
|
|
28
|
+
* Per SECURITY/invariants.yaml §envelope-refusal-no-prompt-leak,
|
|
29
|
+
* `refusalText` MUST be passed through the host's BYOK redaction
|
|
30
|
+
* harness BEFORE persistence — this helper does NOT redact; the
|
|
31
|
+
* caller is responsible for SR-1 carry-forward.
|
|
32
|
+
*
|
|
33
|
+
* @see RFCS/0032-envelope-reliability-events.md §B.3
|
|
34
|
+
* @see RFCS/0033-envelope-completion-contract.md §D + §F
|
|
35
|
+
* @see SECURITY/invariants.yaml §envelope-refusal-no-prompt-leak
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Try to parse the response as OpenAI Chat Completions output.
|
|
39
|
+
*
|
|
40
|
+
* Detection: top-level `choices` array. Refusal signals:
|
|
41
|
+
* - `choices[0].finish_reason === "content_filter"`
|
|
42
|
+
* - `choices[0].message.refusal` is a non-empty string
|
|
43
|
+
*/
|
|
44
|
+
function tryParseOpenAI(response) {
|
|
45
|
+
if (!response || typeof response !== 'object')
|
|
46
|
+
return null;
|
|
47
|
+
const r = response;
|
|
48
|
+
if (!Array.isArray(r.choices) || r.choices.length === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const choice = r.choices[0];
|
|
51
|
+
if (!choice || typeof choice !== 'object')
|
|
52
|
+
return null;
|
|
53
|
+
const finishReason = choice.finish_reason;
|
|
54
|
+
const message = choice.message;
|
|
55
|
+
const refusalField = message && typeof message === 'object' ? message.refusal : undefined;
|
|
56
|
+
// Primary signal: explicit refusal-text field. OpenAI's structured-output
|
|
57
|
+
// release populates this when the safety filter intervenes.
|
|
58
|
+
if (typeof refusalField === 'string' && refusalField.length > 0) {
|
|
59
|
+
return { refusalText: refusalField, provider: 'openai' };
|
|
60
|
+
}
|
|
61
|
+
// Secondary signal: finish_reason. content_filter is the canonical
|
|
62
|
+
// safety-stop value.
|
|
63
|
+
if (finishReason === 'content_filter') {
|
|
64
|
+
const text = message && typeof message === 'object' && typeof message.content === 'string'
|
|
65
|
+
? message.content
|
|
66
|
+
: null;
|
|
67
|
+
return { refusalText: text, safetyCategory: 'content_filter', provider: 'openai' };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Try to parse the response as Anthropic Messages API output.
|
|
73
|
+
*
|
|
74
|
+
* Detection: top-level `stop_reason` field (Anthropic's distinctive
|
|
75
|
+
* marker). Refusal signals:
|
|
76
|
+
* - `stop_reason === "refusal"` (their 2025 release)
|
|
77
|
+
*
|
|
78
|
+
* Anthropic does not surface a distinct safety-category field on
|
|
79
|
+
* refusals; the `refusal` stop_reason is the binary signal.
|
|
80
|
+
*/
|
|
81
|
+
function tryParseAnthropic(response) {
|
|
82
|
+
if (!response || typeof response !== 'object')
|
|
83
|
+
return null;
|
|
84
|
+
const r = response;
|
|
85
|
+
if (typeof r.stop_reason !== 'string')
|
|
86
|
+
return null;
|
|
87
|
+
if (r.stop_reason === 'refusal') {
|
|
88
|
+
// Extract refusal text from the content array (Anthropic returns an
|
|
89
|
+
// array of typed blocks; refusal text appears in `text`-type blocks).
|
|
90
|
+
let refusalText = null;
|
|
91
|
+
if (Array.isArray(r.content)) {
|
|
92
|
+
const textBlocks = [];
|
|
93
|
+
for (const block of r.content) {
|
|
94
|
+
if (block && typeof block === 'object') {
|
|
95
|
+
const b = block;
|
|
96
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
97
|
+
textBlocks.push(b.text);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (textBlocks.length > 0)
|
|
102
|
+
refusalText = textBlocks.join('\n');
|
|
103
|
+
}
|
|
104
|
+
return { refusalText, provider: 'anthropic' };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Try to parse the response as Google Gemini `generateContent` output.
|
|
110
|
+
*
|
|
111
|
+
* Detection: top-level `candidates` array OR top-level `promptFeedback`
|
|
112
|
+
* object. Refusal signals:
|
|
113
|
+
* - `candidates[0].finishReason === "SAFETY"` (output-side block)
|
|
114
|
+
* - `promptFeedback.blockReason === "SAFETY"` (input-side block)
|
|
115
|
+
*
|
|
116
|
+
* Gemini surfaces safety categories on `safetyRatings[]`; this helper
|
|
117
|
+
* picks the highest-probability HIGH/MEDIUM-tier category as
|
|
118
|
+
* `safetyCategory` when available.
|
|
119
|
+
*/
|
|
120
|
+
function tryParseGemini(response) {
|
|
121
|
+
if (!response || typeof response !== 'object')
|
|
122
|
+
return null;
|
|
123
|
+
const r = response;
|
|
124
|
+
// Output-side safety block.
|
|
125
|
+
if (Array.isArray(r.candidates) && r.candidates.length > 0) {
|
|
126
|
+
const candidate = r.candidates[0];
|
|
127
|
+
if (candidate && typeof candidate === 'object' && candidate.finishReason === 'SAFETY') {
|
|
128
|
+
const safetyCategory = extractGeminiHighestRiskCategory(candidate.safetyRatings);
|
|
129
|
+
const result = { refusalText: null, provider: 'google' };
|
|
130
|
+
if (safetyCategory !== undefined)
|
|
131
|
+
result.safetyCategory = safetyCategory;
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Input-side safety block (Gemini rejected the prompt itself).
|
|
136
|
+
if (r.promptFeedback && typeof r.promptFeedback === 'object') {
|
|
137
|
+
const pf = r.promptFeedback;
|
|
138
|
+
if (typeof pf.blockReason === 'string' && pf.blockReason.toUpperCase().includes('SAFETY')) {
|
|
139
|
+
const safetyCategory = extractGeminiHighestRiskCategory(pf.safetyRatings);
|
|
140
|
+
const result = { refusalText: null, provider: 'google' };
|
|
141
|
+
if (safetyCategory !== undefined)
|
|
142
|
+
result.safetyCategory = safetyCategory;
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* From a Gemini `safetyRatings[]` array, return the highest-probability
|
|
150
|
+
* non-NEGLIGIBLE category identifier. Returns `undefined` when the
|
|
151
|
+
* array is absent or all ratings are NEGLIGIBLE.
|
|
152
|
+
*/
|
|
153
|
+
function extractGeminiHighestRiskCategory(safetyRatings) {
|
|
154
|
+
if (!Array.isArray(safetyRatings))
|
|
155
|
+
return undefined;
|
|
156
|
+
const PROBABILITY_RANK = {
|
|
157
|
+
HIGH: 3,
|
|
158
|
+
MEDIUM: 2,
|
|
159
|
+
LOW: 1,
|
|
160
|
+
NEGLIGIBLE: 0,
|
|
161
|
+
};
|
|
162
|
+
let best = null;
|
|
163
|
+
for (const rating of safetyRatings) {
|
|
164
|
+
if (!rating || typeof rating !== 'object')
|
|
165
|
+
continue;
|
|
166
|
+
const r = rating;
|
|
167
|
+
if (typeof r.category !== 'string' || typeof r.probability !== 'string')
|
|
168
|
+
continue;
|
|
169
|
+
const rank = PROBABILITY_RANK[r.probability.toUpperCase()] ?? 0;
|
|
170
|
+
if (rank === 0)
|
|
171
|
+
continue;
|
|
172
|
+
if (best === null || rank > best.rank) {
|
|
173
|
+
best = { category: r.category, rank };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return best?.category;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Parse a provider response into a canonical refusal signal.
|
|
180
|
+
*
|
|
181
|
+
* Returns `null` when no refusal is detected (normal completion).
|
|
182
|
+
* Returns a `RefusalSignal` when the response matches one of the
|
|
183
|
+
* three Tier-1 vendors' safety-stop shapes.
|
|
184
|
+
*
|
|
185
|
+
* Detection order: OpenAI → Anthropic → Gemini. Each detector inspects
|
|
186
|
+
* a distinctive top-level field, so cross-vendor false-positives are
|
|
187
|
+
* unlikely. A response that doesn't match any vendor shape returns
|
|
188
|
+
* `null` (hosts that route through novel providers add their own
|
|
189
|
+
* detector + fall back to this for the three known ones).
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```ts
|
|
193
|
+
* import { parseRefusal } from '@openwop/openwop';
|
|
194
|
+
*
|
|
195
|
+
* const response = await callOpenAI({...});
|
|
196
|
+
* const refusal = parseRefusal(response);
|
|
197
|
+
* if (refusal) {
|
|
198
|
+
* // Route through envelope.refusal emission + envelope_refusal error code.
|
|
199
|
+
* // REMEMBER to redact refusalText through the BYOK harness before
|
|
200
|
+
* // persistence (SECURITY invariant envelope-refusal-no-prompt-leak).
|
|
201
|
+
* await emitEnvelopeRefusal({
|
|
202
|
+
* refusalText: redactBYOK(refusal.refusalText),
|
|
203
|
+
* safetyCategory: refusal.safetyCategory,
|
|
204
|
+
* provider: refusal.provider,
|
|
205
|
+
* });
|
|
206
|
+
* throw new EnvelopeRefusalError(...);
|
|
207
|
+
* }
|
|
208
|
+
* // ...normal-completion handling...
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
export function parseRefusal(providerResponse) {
|
|
212
|
+
return (tryParseOpenAI(providerResponse) ??
|
|
213
|
+
tryParseAnthropic(providerResponse) ??
|
|
214
|
+
tryParseGemini(providerResponse));
|
|
215
|
+
}
|
|
216
|
+
//# sourceMappingURL=parse-refusal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-refusal.js","sourceRoot":"","sources":["../src/parse-refusal.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envelopeDirective — RFC 0030 §A `reasoning`-field prompt directive synthesis.
|
|
3
|
+
*
|
|
4
|
+
* Hosts that advertise `capabilities.envelopes.reasoning.supported: true`
|
|
5
|
+
* with `promptDirective: "advisory"` or `"mandatory"` inject a system-prompt
|
|
6
|
+
* directive instructing the model to populate the OPTIONAL `reasoning` field
|
|
7
|
+
* on envelope payload schemas that carry it. The directive is informational
|
|
8
|
+
* — hosts MUST NOT reject envelopes where `reasoning` is absent regardless
|
|
9
|
+
* of `promptDirective` strength (RFC 0030 §A).
|
|
10
|
+
*
|
|
11
|
+
* The directive fires only when the envelope's `responseSchema` declares a
|
|
12
|
+
* top-level `reasoning` property. Schemas without `reasoning` (e.g.,
|
|
13
|
+
* `schema.response` per RFC 0030 §A) do NOT receive the directive.
|
|
14
|
+
*
|
|
15
|
+
* Honest separation of concerns:
|
|
16
|
+
* - This module decides WHEN to inject (schema has `reasoning`).
|
|
17
|
+
* - The caller decides WHETHER to inject (read `promptDirective` from
|
|
18
|
+
* the host's discovery advertisement).
|
|
19
|
+
* - The model decides whether to ACTUALLY populate the field (the spec
|
|
20
|
+
* forbids rejecting envelopes where `reasoning` is absent).
|
|
21
|
+
*
|
|
22
|
+
* **Operational note on `"mandatory"`** (per RFC 0030 §A 2026-05-21
|
|
23
|
+
* amendment). Strict-output models may honor the mandatory wording
|
|
24
|
+
* literally and refuse mid-emission when reasoning would be vacuous.
|
|
25
|
+
* Hosts SHOULD prefer `"advisory"` unless empirical testing against the
|
|
26
|
+
* active model class confirms `"mandatory"` doesn't trigger refusals.
|
|
27
|
+
*
|
|
28
|
+
* @see RFCS/0030-envelope-reasoning-and-tier-one-subset.md §A
|
|
29
|
+
* @see spec/v1/ai-envelope.md §"Reasoning field (normative)"
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The strength of the host's `reasoning`-directive prompt injection.
|
|
34
|
+
*
|
|
35
|
+
* `"off"` — no directive injected. Caller skips this module entirely.
|
|
36
|
+
* `"advisory"` — directive is suggestive ("populate `reasoning` with your
|
|
37
|
+
* analytical process if the schema permits it"). The
|
|
38
|
+
* spec-recommended default per RFC 0030 §C.
|
|
39
|
+
* `"mandatory"` — directive is firm ("you MUST populate `reasoning` before
|
|
40
|
+
* emitting the structured fields"). Hosts SHOULD prefer
|
|
41
|
+
* `"advisory"` unless model-class-specific testing shows
|
|
42
|
+
* `"mandatory"` is safe.
|
|
43
|
+
*
|
|
44
|
+
* Both `"advisory"` and `"mandatory"` are prompt-injection postures, NOT
|
|
45
|
+
* wire-level refusal contracts — the host accepts envelopes regardless of
|
|
46
|
+
* whether `reasoning` is populated (RFC 0030 §A normative MUST NOT).
|
|
47
|
+
*/
|
|
48
|
+
export type ReasoningDirectiveStrength = 'off' | 'advisory' | 'mandatory';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the directive string to append to the system prompt, OR `null` if
|
|
52
|
+
* the schema does not declare a top-level `reasoning` property.
|
|
53
|
+
*
|
|
54
|
+
* Callers append the returned string to the existing system prompt with a
|
|
55
|
+
* separating newline. When `strength === 'off'`, callers SHOULD short-circuit
|
|
56
|
+
* before invoking this helper (returning `null` here is treated as "no
|
|
57
|
+
* applicable schema," not "directive disabled").
|
|
58
|
+
*
|
|
59
|
+
* The helper inspects only the top-level `properties.reasoning` slot.
|
|
60
|
+
* Nested `reasoning` fields (e.g., inside an `anyOf` branch's payload) are
|
|
61
|
+
* not auto-detected — vendor-kind authors who want per-branch directives
|
|
62
|
+
* synthesize their own.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* import { buildReasoningDirective } from '@openwop/openwop';
|
|
67
|
+
*
|
|
68
|
+
* const directive = buildReasoningDirective(
|
|
69
|
+
* { type: 'object', properties: { reasoning: { type: 'string' }, ... } },
|
|
70
|
+
* 'advisory',
|
|
71
|
+
* );
|
|
72
|
+
* // directive is a string ~80 words; null when schema lacks `reasoning`
|
|
73
|
+
* const systemPrompt = [originalSystemPrompt, schemaHint, directive]
|
|
74
|
+
* .filter((s): s is string => Boolean(s))
|
|
75
|
+
* .join('\n\n');
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function buildReasoningDirective(
|
|
79
|
+
responseSchema: unknown,
|
|
80
|
+
strength: ReasoningDirectiveStrength,
|
|
81
|
+
): string | null {
|
|
82
|
+
if (strength === 'off') return null;
|
|
83
|
+
if (!responseSchema || typeof responseSchema !== 'object') return null;
|
|
84
|
+
|
|
85
|
+
const schema = responseSchema as { properties?: unknown };
|
|
86
|
+
const properties = schema.properties;
|
|
87
|
+
if (!properties || typeof properties !== 'object') return null;
|
|
88
|
+
|
|
89
|
+
const reasoningProp = (properties as { reasoning?: unknown }).reasoning;
|
|
90
|
+
if (!reasoningProp || typeof reasoningProp !== 'object') return null;
|
|
91
|
+
|
|
92
|
+
if (strength === 'mandatory') {
|
|
93
|
+
return [
|
|
94
|
+
'BEFORE emitting the structured fields, populate the `reasoning` property with your analytical',
|
|
95
|
+
'process — explain how you derived each structured field, what assumptions you made, and what',
|
|
96
|
+
'risks or alternative interpretations you considered. The `reasoning` field is REQUIRED in your',
|
|
97
|
+
'output; do not skip it. (Note: the host accepts envelopes where `reasoning` is absent per',
|
|
98
|
+
'RFC 0030 §A, but for this dispatch the host expects it populated.)',
|
|
99
|
+
].join(' ');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// strength === 'advisory'
|
|
103
|
+
return [
|
|
104
|
+
'If your response schema declares a `reasoning` property, populate it as the first field with',
|
|
105
|
+
'your analytical process — explain how you derived each structured field. Per Tam et al. (arXiv',
|
|
106
|
+
"2408.02442), models constrained to strict JSON output suffer reasoning-quality collapse when",
|
|
107
|
+
'no reasoning slot exists; use this field to think before emitting the structured payload. The',
|
|
108
|
+
'host accepts envelopes where `reasoning` is absent — populate it when it improves clarity.',
|
|
109
|
+
].join(' ');
|
|
110
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -157,3 +157,24 @@ export type {
|
|
|
157
157
|
SchemaResponsePayload,
|
|
158
158
|
ValidationDetail,
|
|
159
159
|
} from './types.js';
|
|
160
|
+
|
|
161
|
+
// RFC 0030 §A `reasoning` field prompt-directive helper. Hosts that
|
|
162
|
+
// advertise `capabilities.envelopes.reasoning.supported: true` use this
|
|
163
|
+
// to synthesize the system-prompt fragment that instructs the model to
|
|
164
|
+
// populate the OPTIONAL `reasoning` field. See `envelope-directive.ts`
|
|
165
|
+
// for the operational note on `"mandatory"` strength (provider-side
|
|
166
|
+
// refusal risk per the 2026-05-21 RFC 0030 amendment).
|
|
167
|
+
export { buildReasoningDirective } from './envelope-directive.js';
|
|
168
|
+
export type { ReasoningDirectiveStrength } from './envelope-directive.js';
|
|
169
|
+
|
|
170
|
+
// RFC 0032 §B.3 + RFC 0033 §D refusal detection helper. Normalizes
|
|
171
|
+
// per-provider safety-stop signals (Anthropic `stop_reason: "refusal"`,
|
|
172
|
+
// OpenAI `message.refusal` + `finish_reason: "content_filter"`, Gemini
|
|
173
|
+
// `finishReason: "SAFETY"` + `promptFeedback.blockReason: "SAFETY"`)
|
|
174
|
+
// to a canonical `{ refusalText, safetyCategory?, provider }` shape.
|
|
175
|
+
// Hosts route the non-null return through `envelope.refusal` emission
|
|
176
|
+
// + the `envelope_refusal` terminal error code (RFC 0033 §F).
|
|
177
|
+
// **SECURITY:** Pass `refusalText` through the BYOK redaction harness
|
|
178
|
+
// BEFORE persistence — this helper does NOT redact.
|
|
179
|
+
export { parseRefusal } from './parse-refusal.js';
|
|
180
|
+
export type { RefusalProvider, RefusalSignal } from './parse-refusal.js';
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parseRefusal — normalize per-provider LLM safety-stop signals to the
|
|
3
|
+
* canonical RFC 0032 §B.3 refusal shape.
|
|
4
|
+
*
|
|
5
|
+
* The three Tier-1 vendors (Anthropic / OpenAI / Google Gemini) surface
|
|
6
|
+
* refusals through different fields:
|
|
7
|
+
*
|
|
8
|
+
* - **Anthropic Messages API**: `stop_reason: "refusal"` (their 2025
|
|
9
|
+
* release) OR `stop_reason: "end_turn"` accompanied by safety-stop
|
|
10
|
+
* markers in the content. Refusal text MAY be inline in the
|
|
11
|
+
* `content[]` array's text blocks.
|
|
12
|
+
* - **OpenAI Chat Completions**: `choices[0].finish_reason:
|
|
13
|
+
* "content_filter"` OR `choices[0].message.refusal: <string>` (the
|
|
14
|
+
* refusal-text field added in their structured-output release).
|
|
15
|
+
* - **Google Gemini**: `candidates[0].finishReason: "SAFETY"` OR
|
|
16
|
+
* `promptFeedback.blockReason: "SAFETY"` (input-side block).
|
|
17
|
+
*
|
|
18
|
+
* Without normalization, every host re-implements this detection. This
|
|
19
|
+
* helper consolidates the per-vendor shape-detection and returns a
|
|
20
|
+
* canonical `RefusalSignal | null` — null when the response is a
|
|
21
|
+
* normal completion (no refusal detected).
|
|
22
|
+
*
|
|
23
|
+
* Per RFC 0032 §B.3 + RFC 0033 §D, hosts MUST NOT retry on refusal
|
|
24
|
+
* (circumvention concern). Callers route the non-null return through
|
|
25
|
+
* `envelope.refusal` emission + the `envelope_refusal` terminal error
|
|
26
|
+
* code (per RFC 0033 §F).
|
|
27
|
+
*
|
|
28
|
+
* Per SECURITY/invariants.yaml §envelope-refusal-no-prompt-leak,
|
|
29
|
+
* `refusalText` MUST be passed through the host's BYOK redaction
|
|
30
|
+
* harness BEFORE persistence — this helper does NOT redact; the
|
|
31
|
+
* caller is responsible for SR-1 carry-forward.
|
|
32
|
+
*
|
|
33
|
+
* @see RFCS/0032-envelope-reliability-events.md §B.3
|
|
34
|
+
* @see RFCS/0033-envelope-completion-contract.md §D + §F
|
|
35
|
+
* @see SECURITY/invariants.yaml §envelope-refusal-no-prompt-leak
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Provider identifier for the matched shape. `"unknown"` is reserved
|
|
40
|
+
* for refusals matched on heuristic signals without a definitive
|
|
41
|
+
* vendor-shape match.
|
|
42
|
+
*/
|
|
43
|
+
export type RefusalProvider = 'anthropic' | 'openai' | 'google' | 'unknown';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Canonical refusal signal. All three Tier-1 vendor shapes normalize
|
|
47
|
+
* to this form. `null` from `parseRefusal()` means "no refusal detected"
|
|
48
|
+
* (a normal completion); a non-null `RefusalSignal` means the caller
|
|
49
|
+
* SHOULD route through the RFC 0032 §B.3 refusal-emission path.
|
|
50
|
+
*/
|
|
51
|
+
export interface RefusalSignal {
|
|
52
|
+
/**
|
|
53
|
+
* The provider's refusal text, when surfaced. MAY be `null` even for
|
|
54
|
+
* detected refusals — Anthropic + Gemini frequently refuse without
|
|
55
|
+
* inline text (the safety filter is opaque to the model). OpenAI's
|
|
56
|
+
* `message.refusal` field is the most consistent source of human-
|
|
57
|
+
* readable refusal text.
|
|
58
|
+
*
|
|
59
|
+
* **SECURITY:** When non-null, `refusalText` MAY contain prompt
|
|
60
|
+
* content that triggered the safety filter. Callers MUST pass it
|
|
61
|
+
* through their BYOK + prompt-content redaction harness BEFORE
|
|
62
|
+
* persistence (per `envelope-refusal-no-prompt-leak` SECURITY
|
|
63
|
+
* invariant).
|
|
64
|
+
*/
|
|
65
|
+
refusalText: string | null;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Provider-specific safety-category identifier, when surfaced.
|
|
69
|
+
* Examples: Gemini's safety-rating categories
|
|
70
|
+
* (`HARM_CATEGORY_HARASSMENT`, etc.), OpenAI's content-filter category,
|
|
71
|
+
* Anthropic's policy-violation tag. Hosts MAY echo this on the
|
|
72
|
+
* `envelope.refusal.safetyCategory` event field for downstream
|
|
73
|
+
* observability.
|
|
74
|
+
*/
|
|
75
|
+
safetyCategory?: string;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which provider's shape was matched. Useful for debugging
|
|
79
|
+
* cross-host integration + for hosts that want to route refusals
|
|
80
|
+
* differently per vendor (e.g., different operator notifications).
|
|
81
|
+
*/
|
|
82
|
+
provider: RefusalProvider;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface OpenAIChoiceMessage {
|
|
86
|
+
refusal?: unknown;
|
|
87
|
+
content?: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface OpenAIChoice {
|
|
91
|
+
finish_reason?: unknown;
|
|
92
|
+
message?: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface OpenAIResponse {
|
|
96
|
+
choices?: unknown;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface AnthropicTextBlock {
|
|
100
|
+
type?: unknown;
|
|
101
|
+
text?: unknown;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface AnthropicResponse {
|
|
105
|
+
stop_reason?: unknown;
|
|
106
|
+
content?: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface GeminiSafetyRating {
|
|
110
|
+
category?: unknown;
|
|
111
|
+
probability?: unknown;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface GeminiCandidate {
|
|
115
|
+
finishReason?: unknown;
|
|
116
|
+
safetyRatings?: unknown;
|
|
117
|
+
content?: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface GeminiPromptFeedback {
|
|
121
|
+
blockReason?: unknown;
|
|
122
|
+
safetyRatings?: unknown;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface GeminiResponse {
|
|
126
|
+
candidates?: unknown;
|
|
127
|
+
promptFeedback?: unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Try to parse the response as OpenAI Chat Completions output.
|
|
132
|
+
*
|
|
133
|
+
* Detection: top-level `choices` array. Refusal signals:
|
|
134
|
+
* - `choices[0].finish_reason === "content_filter"`
|
|
135
|
+
* - `choices[0].message.refusal` is a non-empty string
|
|
136
|
+
*/
|
|
137
|
+
function tryParseOpenAI(response: unknown): RefusalSignal | null {
|
|
138
|
+
if (!response || typeof response !== 'object') return null;
|
|
139
|
+
const r = response as OpenAIResponse;
|
|
140
|
+
if (!Array.isArray(r.choices) || r.choices.length === 0) return null;
|
|
141
|
+
const choice = r.choices[0] as OpenAIChoice;
|
|
142
|
+
if (!choice || typeof choice !== 'object') return null;
|
|
143
|
+
|
|
144
|
+
const finishReason = choice.finish_reason;
|
|
145
|
+
const message = choice.message as OpenAIChoiceMessage | undefined;
|
|
146
|
+
const refusalField = message && typeof message === 'object' ? message.refusal : undefined;
|
|
147
|
+
|
|
148
|
+
// Primary signal: explicit refusal-text field. OpenAI's structured-output
|
|
149
|
+
// release populates this when the safety filter intervenes.
|
|
150
|
+
if (typeof refusalField === 'string' && refusalField.length > 0) {
|
|
151
|
+
return { refusalText: refusalField, provider: 'openai' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Secondary signal: finish_reason. content_filter is the canonical
|
|
155
|
+
// safety-stop value.
|
|
156
|
+
if (finishReason === 'content_filter') {
|
|
157
|
+
const text =
|
|
158
|
+
message && typeof message === 'object' && typeof message.content === 'string'
|
|
159
|
+
? message.content
|
|
160
|
+
: null;
|
|
161
|
+
return { refusalText: text, safetyCategory: 'content_filter', provider: 'openai' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Try to parse the response as Anthropic Messages API output.
|
|
169
|
+
*
|
|
170
|
+
* Detection: top-level `stop_reason` field (Anthropic's distinctive
|
|
171
|
+
* marker). Refusal signals:
|
|
172
|
+
* - `stop_reason === "refusal"` (their 2025 release)
|
|
173
|
+
*
|
|
174
|
+
* Anthropic does not surface a distinct safety-category field on
|
|
175
|
+
* refusals; the `refusal` stop_reason is the binary signal.
|
|
176
|
+
*/
|
|
177
|
+
function tryParseAnthropic(response: unknown): RefusalSignal | null {
|
|
178
|
+
if (!response || typeof response !== 'object') return null;
|
|
179
|
+
const r = response as AnthropicResponse;
|
|
180
|
+
if (typeof r.stop_reason !== 'string') return null;
|
|
181
|
+
|
|
182
|
+
if (r.stop_reason === 'refusal') {
|
|
183
|
+
// Extract refusal text from the content array (Anthropic returns an
|
|
184
|
+
// array of typed blocks; refusal text appears in `text`-type blocks).
|
|
185
|
+
let refusalText: string | null = null;
|
|
186
|
+
if (Array.isArray(r.content)) {
|
|
187
|
+
const textBlocks: string[] = [];
|
|
188
|
+
for (const block of r.content) {
|
|
189
|
+
if (block && typeof block === 'object') {
|
|
190
|
+
const b = block as AnthropicTextBlock;
|
|
191
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
192
|
+
textBlocks.push(b.text);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (textBlocks.length > 0) refusalText = textBlocks.join('\n');
|
|
197
|
+
}
|
|
198
|
+
return { refusalText, provider: 'anthropic' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Try to parse the response as Google Gemini `generateContent` output.
|
|
206
|
+
*
|
|
207
|
+
* Detection: top-level `candidates` array OR top-level `promptFeedback`
|
|
208
|
+
* object. Refusal signals:
|
|
209
|
+
* - `candidates[0].finishReason === "SAFETY"` (output-side block)
|
|
210
|
+
* - `promptFeedback.blockReason === "SAFETY"` (input-side block)
|
|
211
|
+
*
|
|
212
|
+
* Gemini surfaces safety categories on `safetyRatings[]`; this helper
|
|
213
|
+
* picks the highest-probability HIGH/MEDIUM-tier category as
|
|
214
|
+
* `safetyCategory` when available.
|
|
215
|
+
*/
|
|
216
|
+
function tryParseGemini(response: unknown): RefusalSignal | null {
|
|
217
|
+
if (!response || typeof response !== 'object') return null;
|
|
218
|
+
const r = response as GeminiResponse;
|
|
219
|
+
|
|
220
|
+
// Output-side safety block.
|
|
221
|
+
if (Array.isArray(r.candidates) && r.candidates.length > 0) {
|
|
222
|
+
const candidate = r.candidates[0] as GeminiCandidate;
|
|
223
|
+
if (candidate && typeof candidate === 'object' && candidate.finishReason === 'SAFETY') {
|
|
224
|
+
const safetyCategory = extractGeminiHighestRiskCategory(candidate.safetyRatings);
|
|
225
|
+
const result: RefusalSignal = { refusalText: null, provider: 'google' };
|
|
226
|
+
if (safetyCategory !== undefined) result.safetyCategory = safetyCategory;
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Input-side safety block (Gemini rejected the prompt itself).
|
|
232
|
+
if (r.promptFeedback && typeof r.promptFeedback === 'object') {
|
|
233
|
+
const pf = r.promptFeedback as GeminiPromptFeedback;
|
|
234
|
+
if (typeof pf.blockReason === 'string' && pf.blockReason.toUpperCase().includes('SAFETY')) {
|
|
235
|
+
const safetyCategory = extractGeminiHighestRiskCategory(pf.safetyRatings);
|
|
236
|
+
const result: RefusalSignal = { refusalText: null, provider: 'google' };
|
|
237
|
+
if (safetyCategory !== undefined) result.safetyCategory = safetyCategory;
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* From a Gemini `safetyRatings[]` array, return the highest-probability
|
|
247
|
+
* non-NEGLIGIBLE category identifier. Returns `undefined` when the
|
|
248
|
+
* array is absent or all ratings are NEGLIGIBLE.
|
|
249
|
+
*/
|
|
250
|
+
function extractGeminiHighestRiskCategory(safetyRatings: unknown): string | undefined {
|
|
251
|
+
if (!Array.isArray(safetyRatings)) return undefined;
|
|
252
|
+
const PROBABILITY_RANK: Record<string, number> = {
|
|
253
|
+
HIGH: 3,
|
|
254
|
+
MEDIUM: 2,
|
|
255
|
+
LOW: 1,
|
|
256
|
+
NEGLIGIBLE: 0,
|
|
257
|
+
};
|
|
258
|
+
let best: { category: string; rank: number } | null = null;
|
|
259
|
+
for (const rating of safetyRatings) {
|
|
260
|
+
if (!rating || typeof rating !== 'object') continue;
|
|
261
|
+
const r = rating as GeminiSafetyRating;
|
|
262
|
+
if (typeof r.category !== 'string' || typeof r.probability !== 'string') continue;
|
|
263
|
+
const rank = PROBABILITY_RANK[r.probability.toUpperCase()] ?? 0;
|
|
264
|
+
if (rank === 0) continue;
|
|
265
|
+
if (best === null || rank > best.rank) {
|
|
266
|
+
best = { category: r.category, rank };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return best?.category;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse a provider response into a canonical refusal signal.
|
|
274
|
+
*
|
|
275
|
+
* Returns `null` when no refusal is detected (normal completion).
|
|
276
|
+
* Returns a `RefusalSignal` when the response matches one of the
|
|
277
|
+
* three Tier-1 vendors' safety-stop shapes.
|
|
278
|
+
*
|
|
279
|
+
* Detection order: OpenAI → Anthropic → Gemini. Each detector inspects
|
|
280
|
+
* a distinctive top-level field, so cross-vendor false-positives are
|
|
281
|
+
* unlikely. A response that doesn't match any vendor shape returns
|
|
282
|
+
* `null` (hosts that route through novel providers add their own
|
|
283
|
+
* detector + fall back to this for the three known ones).
|
|
284
|
+
*
|
|
285
|
+
* @example
|
|
286
|
+
* ```ts
|
|
287
|
+
* import { parseRefusal } from '@openwop/openwop';
|
|
288
|
+
*
|
|
289
|
+
* const response = await callOpenAI({...});
|
|
290
|
+
* const refusal = parseRefusal(response);
|
|
291
|
+
* if (refusal) {
|
|
292
|
+
* // Route through envelope.refusal emission + envelope_refusal error code.
|
|
293
|
+
* // REMEMBER to redact refusalText through the BYOK harness before
|
|
294
|
+
* // persistence (SECURITY invariant envelope-refusal-no-prompt-leak).
|
|
295
|
+
* await emitEnvelopeRefusal({
|
|
296
|
+
* refusalText: redactBYOK(refusal.refusalText),
|
|
297
|
+
* safetyCategory: refusal.safetyCategory,
|
|
298
|
+
* provider: refusal.provider,
|
|
299
|
+
* });
|
|
300
|
+
* throw new EnvelopeRefusalError(...);
|
|
301
|
+
* }
|
|
302
|
+
* // ...normal-completion handling...
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
export function parseRefusal(providerResponse: unknown): RefusalSignal | null {
|
|
306
|
+
return (
|
|
307
|
+
tryParseOpenAI(providerResponse) ??
|
|
308
|
+
tryParseAnthropic(providerResponse) ??
|
|
309
|
+
tryParseGemini(providerResponse)
|
|
310
|
+
);
|
|
311
|
+
}
|