@soffits/dsh-fallback-models 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +150 -2
- package/client.js +511 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +8 -0
- package/index.js +20 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/src/config.js +33 -0
- package/src/fallback.js +354 -0
- package/src/policy.js +134 -0
- package/src/refusal.js +163 -0
package/src/refusal.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Refusal detection. A provider's content filter or a model that declines
|
|
2
|
+
// answers with an ordinary successful response, so DSH reports no failure and
|
|
3
|
+
// no recovery runs. This module recognises such a reply at the end of its
|
|
4
|
+
// stream and replaces the stream's `stop` finish with an `error` finish of
|
|
5
|
+
// code REFUSAL. The agent loop then records the reply as a failed attempt
|
|
6
|
+
// (kept in the log, never shown as the answer and never sent to a model) and
|
|
7
|
+
// asks `agent/request-error`, where the ordinary fallback chain takes over.
|
|
8
|
+
//
|
|
9
|
+
// Detection is deliberately narrow, because a false positive throws away a
|
|
10
|
+
// good answer and pays for a second model. All of these must hold:
|
|
11
|
+
// - the reply has text and no tool calls, and stopped normally;
|
|
12
|
+
// - no tool call succeeded since the user's last message (a turn that already
|
|
13
|
+
// did work is not refused, whatever its last words are);
|
|
14
|
+
// - the reply is short (`maxLength` characters);
|
|
15
|
+
// - a pattern matches within its opening (`OPENING` characters), not right
|
|
16
|
+
// after a quotation mark, so a reply that quotes or discusses a refusal
|
|
17
|
+
// later on is left alone.
|
|
18
|
+
|
|
19
|
+
import { REFUSAL_CODE } from './policy.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Built-in refusal signatures: regular-expression sources, matched
|
|
23
|
+
* case-insensitively. They cover provider filter notices and the common
|
|
24
|
+
* opening sentences of a declined request, in English and Chinese.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_REFUSAL_PATTERNS = Object.freeze([
|
|
27
|
+
// Provider filter notices (Gemini: "This request was blocked by Google's filters").
|
|
28
|
+
String.raw`\b(?:this|the|your) (?:request|prompt|response|content|message) (?:was|has been|is) blocked\b`,
|
|
29
|
+
String.raw`\bblocked by (?:[\w'’ ]{0,40})?(?:safety|content) (?:filters?|policy|system)\b`,
|
|
30
|
+
// "I can't help with that", "I cannot assist with this request", "I won't help with ...".
|
|
31
|
+
String.raw`\bI (?:can['’]?t|cannot|can not|won['’]?t|will not|am unable to|am not able to) (?:help|assist)(?: you)? with (?:that|this|it|your request|the request)\b`,
|
|
32
|
+
// "I'm not able to assist", "I'm unable to help with that", "Sorry, I'm not able to help".
|
|
33
|
+
String.raw`\bI['’]?m (?:not able|unable|not going) to (?:help|assist)\b`,
|
|
34
|
+
// "I can't provide/fulfil/comply with that".
|
|
35
|
+
String.raw`\bI (?:can['’]?t|cannot|won['’]?t|will not) (?:provide|fulfill|fulfil|comply with) (?:that|this)(?: request)?\b`,
|
|
36
|
+
// Chinese: 我不能协助 / 我无法帮助 / 抱歉,不能提供这类内容 ...
|
|
37
|
+
String.raw`我(?:不能|无法|没法|不可以|不会|不便)(?:协助|帮助|帮你|帮您|帮忙|提供|满足|完成|回答|处理)`,
|
|
38
|
+
String.raw`(?:抱歉|对不起)[,,。!!\s]*(?:不能|无法)(?:协助|帮助|提供|满足|回答)(?:这|此|你|您)`,
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/** Default upper bound on a refusal's length, in characters. */
|
|
42
|
+
export const DEFAULT_REFUSAL_MAX_LENGTH = 500
|
|
43
|
+
|
|
44
|
+
/** A signature must start within this many characters of the reply. */
|
|
45
|
+
export const OPENING = 160
|
|
46
|
+
|
|
47
|
+
const QUOTES = new Set(['"', '“', '”', '\'', '‘', '’', '「', '『', '`', '«'])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compile configured pattern sources, skipping (and reporting) invalid ones.
|
|
51
|
+
* @param sources - regular-expression sources.
|
|
52
|
+
* @param warn - called with (source, message) for each invalid pattern.
|
|
53
|
+
* @returns compiled case-insensitive Unicode expressions.
|
|
54
|
+
*/
|
|
55
|
+
export function compilePatterns(sources, warn = () => {}) {
|
|
56
|
+
const out = []
|
|
57
|
+
for (const source of sources ?? []) {
|
|
58
|
+
if (typeof source !== 'string' || source.trim() === '') continue
|
|
59
|
+
try {
|
|
60
|
+
out.push(new RegExp(source, 'iu'))
|
|
61
|
+
} catch (error) {
|
|
62
|
+
warn(source, error instanceof Error ? error.message : String(error))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a finished reply's text reads as a refusal or a filter block.
|
|
70
|
+
* @param text - the reply's visible text.
|
|
71
|
+
* @param patterns - compiled signatures.
|
|
72
|
+
* @param maxLength - longest text still treated as a refusal.
|
|
73
|
+
* @returns the matched snippet, or undefined.
|
|
74
|
+
*/
|
|
75
|
+
export function refusalMatch(text, patterns, maxLength = DEFAULT_REFUSAL_MAX_LENGTH) {
|
|
76
|
+
const reply = String(text ?? '').trim()
|
|
77
|
+
if (reply === '' || reply.length > maxLength) return undefined
|
|
78
|
+
const opening = reply.slice(0, OPENING + 80)
|
|
79
|
+
for (const pattern of patterns) {
|
|
80
|
+
const match = pattern.exec(opening)
|
|
81
|
+
if (!match || match.index > OPENING) continue
|
|
82
|
+
if (QUOTES.has(opening[match.index - 1])) continue
|
|
83
|
+
return match[0]
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether a tool call succeeded since the last message the user wrote, read
|
|
90
|
+
* from the request's history (a loop-built request is the session's
|
|
91
|
+
* model-visible log).
|
|
92
|
+
* @param messages - the request messages.
|
|
93
|
+
*/
|
|
94
|
+
export function toolSucceededThisTurn(messages) {
|
|
95
|
+
for (let i = (messages?.length ?? 0) - 1; i >= 0; i--) {
|
|
96
|
+
const message = messages[i]
|
|
97
|
+
if (message.role === 'tool') {
|
|
98
|
+
if (message.isError !== true) return true
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (message.role === 'user' && message.source?.kind === 'user') return false
|
|
102
|
+
}
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The reply's visible text and whether it called tools, from its stream chunks. */
|
|
107
|
+
class ReplyReader {
|
|
108
|
+
constructor() {
|
|
109
|
+
this.deltas = new Map()
|
|
110
|
+
this.blocks = new Map()
|
|
111
|
+
this.toolCalls = false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
push(chunk) {
|
|
115
|
+
if (chunk.type === 'text-delta') this.deltas.set(chunk.index, (this.deltas.get(chunk.index) ?? '') + chunk.text)
|
|
116
|
+
else if (chunk.type === 'tool-call-delta') this.toolCalls = true
|
|
117
|
+
else if (chunk.type === 'block-start' && chunk.blockType === 'tool-call') this.toolCalls = true
|
|
118
|
+
else if (chunk.type === 'block-end') {
|
|
119
|
+
if (chunk.block?.type === 'text') this.blocks.set(chunk.index, chunk.block.text)
|
|
120
|
+
else if (chunk.block?.type === 'tool-call') this.toolCalls = true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
text() {
|
|
125
|
+
const indexes = [...new Set([...this.deltas.keys(), ...this.blocks.keys()])].sort((a, b) => a - b)
|
|
126
|
+
return indexes.map(index => this.blocks.get(index) ?? this.deltas.get(index)).join('\n')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The failure reported for a detected refusal. */
|
|
131
|
+
export function refusalFailure(text) {
|
|
132
|
+
const reply = String(text).replace(/\s+/g, ' ').trim()
|
|
133
|
+
const short = reply.length > 160 ? `${reply.slice(0, 159)}…` : reply
|
|
134
|
+
return Object.freeze({ code: REFUSAL_CODE, message: `the model declined to answer: "${short}"` })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Pass a model stream through unchanged, except that a normal finish of a
|
|
139
|
+
* reply that `check` accepts as a refusal becomes an error finish with code
|
|
140
|
+
* REFUSAL. Chunks are not held back, so answers stream as before.
|
|
141
|
+
* @param stream - the inner stream.
|
|
142
|
+
* @param check - async (text) => boolean, asked only for a finished, short,
|
|
143
|
+
* tool-free reply whose opening matched a pattern.
|
|
144
|
+
* @param options - `{ patterns, maxLength }`.
|
|
145
|
+
*/
|
|
146
|
+
export async function * watchRefusal(stream, check, { patterns, maxLength }) {
|
|
147
|
+
const reader = new ReplyReader()
|
|
148
|
+
for await (const chunk of stream) {
|
|
149
|
+
if (chunk.type !== 'finish') {
|
|
150
|
+
reader.push(chunk)
|
|
151
|
+
yield chunk
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (chunk.reason?.kind === 'stop' && !reader.toolCalls) {
|
|
155
|
+
const text = reader.text()
|
|
156
|
+
if (refusalMatch(text, patterns, maxLength) !== undefined && await check(text)) {
|
|
157
|
+
yield { type: 'finish', reason: { kind: 'error', failure: refusalFailure(text) } }
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
yield chunk
|
|
162
|
+
}
|
|
163
|
+
}
|