@apinggithub/mydsh-auto-model-router 0.0.0-stage → 0.2.1
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 +522 -3
- package/README.zh.md +463 -0
- package/auto-adapter.mjs +132 -0
- package/classifier.mjs +86 -0
- package/client.js +123 -0
- package/compat.mjs +199 -0
- package/config.mjs +461 -0
- package/cordis.patch.yml +11 -0
- package/cost-control.mjs +159 -0
- package/fallback.mjs +62 -0
- package/index.mjs +429 -0
- package/notify.mjs +653 -0
- package/package.json +74 -6
- package/router.mjs +751 -0
- package/rules.mjs +59 -0
- package/scoring.mjs +69 -0
- package/text-width.mjs +73 -0
package/rules.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Rule layer (tier 1): zero-cost deterministic routing.
|
|
2
|
+
// Rules are tried in order; the first rule whose pattern matches the
|
|
3
|
+
// classification text wins and returns a LEVEL id (L0…L3), which the router
|
|
4
|
+
// maps to the user-configured model for that level.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Match the classification text against every rule.
|
|
8
|
+
* @param {object[]} rules - normalized rules from resolveConfig.
|
|
9
|
+
* @param {string} text - the text to classify against.
|
|
10
|
+
* @returns {string | null} the first matching rule's level, or null.
|
|
11
|
+
*/
|
|
12
|
+
export function matchRules(rules, text) {
|
|
13
|
+
if (!rules || rules.length === 0) return null
|
|
14
|
+
const haystack = String(text ?? '')
|
|
15
|
+
if (haystack.trim() === '') return null
|
|
16
|
+
for (const rule of rules) {
|
|
17
|
+
if (rule.regex) {
|
|
18
|
+
rule.regex.lastIndex = 0
|
|
19
|
+
if (rule.regex.test(haystack)) return rule.level
|
|
20
|
+
} else if (haystack.toLowerCase().includes(rule.match.toLowerCase())) {
|
|
21
|
+
return rule.level
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the text the router classifies on for one step.
|
|
29
|
+
* Concatenates the latest user message content plus the session's cwd so
|
|
30
|
+
* rules can match paths ("src/api", "migrations").
|
|
31
|
+
* @param {object} options - { messages, cwd }.
|
|
32
|
+
* @returns {string} classification haystack.
|
|
33
|
+
*/
|
|
34
|
+
export function classificationText({ messages = [], cwd = '' } = {}) {
|
|
35
|
+
const parts = []
|
|
36
|
+
for (let i = messages.length - 1; i >= 0 && parts.length < 3; i--) {
|
|
37
|
+
const message = messages[i]
|
|
38
|
+
if (message?.role !== 'user') continue
|
|
39
|
+
const text = extractText(message)
|
|
40
|
+
if (text) {
|
|
41
|
+
parts.unshift(text)
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (cwd) parts.push(` cwd:${cwd}`)
|
|
46
|
+
return parts.join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractText(message) {
|
|
50
|
+
const content = message?.content
|
|
51
|
+
if (typeof content === 'string') return content
|
|
52
|
+
if (Array.isArray(content)) {
|
|
53
|
+
return content
|
|
54
|
+
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
|
|
55
|
+
.join('\n')
|
|
56
|
+
.trim()
|
|
57
|
+
}
|
|
58
|
+
return ''
|
|
59
|
+
}
|
package/scoring.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Keyword scoring for fully-auto mode (MODE C).
|
|
2
|
+
//
|
|
3
|
+
// Each rule carries a level; every keyword HIT in the window text (本次输入 +
|
|
4
|
+
// 上次输入 + 上次回复) adds the level's weight once per occurrence (counted
|
|
5
|
+
// per match, so a message mentioning "refactor" three times scores 3×).
|
|
6
|
+
// The accumulated score maps to a level through the configured bands.
|
|
7
|
+
// A score of 0 means no rule hit → the router falls through to the LLM
|
|
8
|
+
// classifier.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Score one window text against the rules and return the level band.
|
|
12
|
+
* @param {object[]} rules - [{ match, regex, level }]
|
|
13
|
+
* @param {object} scoring - { weights, bands }
|
|
14
|
+
* @param {string} text - the scoring window text.
|
|
15
|
+
* @returns {{ level: string|null, score: number, hits: number }}
|
|
16
|
+
* level null when the score resolves to the 0 band (caller → classifier).
|
|
17
|
+
*/
|
|
18
|
+
export function scoreRules(rules, scoring, text) {
|
|
19
|
+
const haystack = String(text ?? '')
|
|
20
|
+
let score = 0
|
|
21
|
+
let hits = 0
|
|
22
|
+
if (haystack.trim() !== '') {
|
|
23
|
+
for (const rule of rules) {
|
|
24
|
+
const weight = scoring.weights[rule.level] ?? 1
|
|
25
|
+
let count = 0
|
|
26
|
+
if (rule.regex) {
|
|
27
|
+
rule.regex.lastIndex = 0
|
|
28
|
+
const copy = new RegExp(rule.regex.source, rule.regex.flags.includes('g') ? rule.regex.flags : `${rule.regex.flags}g`)
|
|
29
|
+
let m
|
|
30
|
+
while ((m = copy.exec(haystack)) !== null) {
|
|
31
|
+
count++
|
|
32
|
+
if (m[0] === '') copy.lastIndex++
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
// Count non-overlapping substring occurrences case-insensitively.
|
|
36
|
+
const needle = rule.match.toLowerCase()
|
|
37
|
+
const lower = haystack.toLowerCase()
|
|
38
|
+
let idx = lower.indexOf(needle)
|
|
39
|
+
while (idx !== -1) {
|
|
40
|
+
count++
|
|
41
|
+
idx = lower.indexOf(needle, idx + needle.length)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (count > 0) {
|
|
45
|
+
hits += count
|
|
46
|
+
score += count * weight
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const level = bandLevel(scoring.bands, score)
|
|
51
|
+
return { level, score, hits }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map a score to a level through the bands (first band whose maxScore
|
|
56
|
+
* contains the score wins; null maxScore is a catch-all).
|
|
57
|
+
* A score of 0 resolves to null — the router falls through to the LLM
|
|
58
|
+
* classifier instead of pinning to the lowest level directly.
|
|
59
|
+
* @param {object[]} bands - [{ maxScore, level }]
|
|
60
|
+
* @param {number} score
|
|
61
|
+
* @returns {string | null} level id, or null when score is 0.
|
|
62
|
+
*/
|
|
63
|
+
export function bandLevel(bands, score) {
|
|
64
|
+
if (score <= 0) return null
|
|
65
|
+
for (const band of bands) {
|
|
66
|
+
if (band.maxScore === null || score <= band.maxScore) return band.level
|
|
67
|
+
}
|
|
68
|
+
return bands[bands.length - 1]?.level ?? null
|
|
69
|
+
}
|
package/text-width.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Text-width counting for the heuristic lock.
|
|
2
|
+
//
|
|
3
|
+
// counting modes:
|
|
4
|
+
// cjk2 — CJK codepoints (>= U+3000) count 2, everything else 1.
|
|
5
|
+
// A Chinese char is ~1.5 tokens vs an ASCII char ~0.25 tokens;
|
|
6
|
+
// doubling the CJK width keeps "10 chars" intuitive for Chinese.
|
|
7
|
+
// tokens — token estimation: CJK at 1.5/char, other at 0.25/char (chars/4).
|
|
8
|
+
|
|
9
|
+
const CJK_MIN = 0x3000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Width of one string under the configured counting mode.
|
|
13
|
+
* @param {string} text
|
|
14
|
+
* @param {'cjk2'|'tokens'} [mode]
|
|
15
|
+
* @returns {number}
|
|
16
|
+
*/
|
|
17
|
+
export function textWidth(text, mode = 'cjk2') {
|
|
18
|
+
const s = String(text ?? '')
|
|
19
|
+
let cjk = 0
|
|
20
|
+
for (let i = 0; i < s.length; i++) {
|
|
21
|
+
if (s.charCodeAt(i) >= CJK_MIN) cjk++
|
|
22
|
+
}
|
|
23
|
+
const other = s.length - cjk
|
|
24
|
+
if (mode === 'tokens') {
|
|
25
|
+
return Math.ceil(cjk * 1.5 + other / 4)
|
|
26
|
+
}
|
|
27
|
+
return cjk * 2 + other
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sum the widths of the last `windowSize` texts in order.
|
|
32
|
+
* @param {string[]} texts - newest-last.
|
|
33
|
+
* @param {number} windowSize
|
|
34
|
+
* @param {'cjk2'|'tokens'} [mode]
|
|
35
|
+
* @returns {number}
|
|
36
|
+
*/
|
|
37
|
+
export function windowWidth(texts, windowSize, mode = 'cjk2') {
|
|
38
|
+
const tail = texts.slice(-windowSize)
|
|
39
|
+
return tail.reduce((sum, text) => sum + textWidth(text, mode), 0)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a level from the heuristic thresholds for a width.
|
|
44
|
+
* Thresholds are ordered ascending; the FIRST threshold with
|
|
45
|
+
* `width <= maxChars` wins. A threshold with `maxChars: null` is a catch-all.
|
|
46
|
+
* Widths above every finite threshold resolve to the LAST threshold's level
|
|
47
|
+
* (the top of the lock); callers that need to distinguish "above the lock"
|
|
48
|
+
* check the top finite threshold themselves.
|
|
49
|
+
* @param {object[]} thresholds - [{ maxChars, level }]
|
|
50
|
+
* @param {number} width
|
|
51
|
+
* @returns {string} LEVEL_IDS entry
|
|
52
|
+
*/
|
|
53
|
+
export function levelForWidth(thresholds, width) {
|
|
54
|
+
for (const t of thresholds) {
|
|
55
|
+
if (t.maxChars === null || width <= t.maxChars) return t.level
|
|
56
|
+
}
|
|
57
|
+
return thresholds[thresholds.length - 1]?.level ?? 'L1'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The top finite threshold's maxChars, or null when every threshold is
|
|
62
|
+
* unbounded. Used by the router to detect "above the heuristic lock".
|
|
63
|
+
* @param {object[]} thresholds
|
|
64
|
+
* @returns {number | null}
|
|
65
|
+
*/
|
|
66
|
+
export function lockCeiling(thresholds) {
|
|
67
|
+
for (let i = thresholds.length - 1; i >= 0; i--) {
|
|
68
|
+
if (thresholds[i]?.maxChars !== null && thresholds[i]?.maxChars !== undefined) {
|
|
69
|
+
return thresholds[i].maxChars
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|