openclaw-freerouter 1.3.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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Default Routing Config — Customized for Direct API Keys
3
+ * Forked from ClawRouter (MIT License). No payment dependencies.
4
+ *
5
+ * Tier models are mapped to providers YOU have API keys for.
6
+ * Edit the `tiers` section to match your configured providers.
7
+ *
8
+ * Available providers (from openclaw.json):
9
+ * - anthropic: claude-opus-4-6
10
+ * - kimi-coding: kimi-for-coding (Kimi K2.5)
11
+ * - openai: gpt-4o, gpt-4o-mini, o3, o3-mini (add as needed)
12
+ * - google: gemini-2.5-pro, gemini-2.5-flash (add as needed)
13
+ */
14
+
15
+ import type { RoutingConfig } from "./types.js";
16
+ import { getConfig } from "../config.js";
17
+
18
+ export const DEFAULT_ROUTING_CONFIG: RoutingConfig = {
19
+ version: "2.0-direct",
20
+
21
+ classifier: {
22
+ llmModel: "kimi-coding/kimi-for-coding", // cheapest for classification fallback
23
+ llmMaxTokens: 10,
24
+ llmTemperature: 0,
25
+ promptTruncationChars: 500,
26
+ cacheTtlMs: 3_600_000,
27
+ },
28
+
29
+ scoring: {
30
+ tokenCountThresholds: { simple: 5, complex: 40 },
31
+
32
+ // ─── Multilingual keyword lists (unchanged from upstream) ───
33
+
34
+ codeKeywords: [
35
+ "function", "class", "import", "def", "SELECT", "async", "await",
36
+ "const", "let", "var", "return", "```",
37
+ "函数", "类", "导入", "定义", "查询", "异步", "等待", "常量", "变量", "返回",
38
+ "関数", "クラス", "インポート", "非同期", "定数", "変数",
39
+ "функция", "класс", "импорт", "определ", "запрос", "асинхронный", "ожидать", "константа", "переменная", "вернуть",
40
+ "funktion", "klasse", "importieren", "definieren", "abfrage", "asynchron", "erwarten", "konstante", "variable", "zurückgeben",
41
+ ],
42
+ reasoningKeywords: [
43
+ "prove", "theorem", "derive", "step by step", "chain of thought",
44
+ "formally", "mathematical", "proof", "logically",
45
+ "证明", "定理", "推导", "逐步", "思维链", "形式化", "数学", "逻辑",
46
+ "証明", "定理", "導出", "ステップバイステップ", "論理的",
47
+ "доказать", "докажи", "доказательств", "теорема", "вывести", "шаг за шагом", "пошагово", "поэтапно", "цепочка рассуждений", "рассуждени", "формально", "математически", "логически",
48
+ "beweisen", "beweis", "theorem", "ableiten", "schritt für schritt", "gedankenkette", "formal", "mathematisch", "logisch",
49
+ ],
50
+ simpleKeywords: [
51
+ "what is", "define", "translate", "hello", "yes or no", "capital of",
52
+ "how old", "who is", "when was",
53
+ "什么是", "定义", "翻译", "你好", "是否", "首都", "多大", "谁是", "何时",
54
+ "とは", "定義", "翻訳", "こんにちは", "はいかいいえ", "首都", "誰",
55
+ "что такое", "определение", "перевести", "переведи", "привет", "да или нет", "столица", "сколько лет", "кто такой", "когда", "объясни",
56
+ "was ist", "definiere", "übersetze", "hallo", "ja oder nein", "hauptstadt", "wie alt", "wer ist", "wann", "erkläre",
57
+ ],
58
+ technicalKeywords: [
59
+ "algorithm", "optimize", "architecture", "distributed", "kubernetes",
60
+ "microservice", "database", "infrastructure",
61
+ "算法", "优化", "架构", "分布式", "微服务", "数据库", "基础设施",
62
+ "アルゴリズム", "最適化", "アーキテクチャ", "分散", "マイクロサービス", "データベース",
63
+ "алгоритм", "оптимизировать", "оптимизаци", "оптимизируй", "архитектура", "распределённый", "микросервис", "база данных", "инфраструктура",
64
+ "algorithmus", "optimieren", "architektur", "verteilt", "kubernetes", "mikroservice", "datenbank", "infrastruktur",
65
+ ],
66
+ creativeKeywords: [
67
+ "story", "poem", "compose", "brainstorm", "creative", "imagine", "write a",
68
+ "故事", "诗", "创作", "头脑风暴", "创意", "想象", "写一个",
69
+ "物語", "詩", "作曲", "ブレインストーム", "創造的", "想像",
70
+ "история", "рассказ", "стихотворение", "сочинить", "сочини", "мозговой штурм", "творческий", "представить", "придумай", "напиши",
71
+ "geschichte", "gedicht", "komponieren", "brainstorming", "kreativ", "vorstellen", "schreibe", "erzählung",
72
+ ],
73
+
74
+ imperativeVerbs: [
75
+ "build", "create", "implement", "design", "develop", "construct",
76
+ "generate", "deploy", "configure", "set up",
77
+ "构建", "创建", "实现", "设计", "开发", "生成", "部署", "配置", "设置",
78
+ "構築", "作成", "実装", "設計", "開発", "生成", "デプロイ", "設定",
79
+ "построить", "построй", "создать", "создай", "реализовать", "реализуй", "спроектировать", "разработать", "разработай", "сконструировать", "сгенерировать", "сгенерируй", "развернуть", "разверни", "настроить", "настрой",
80
+ "erstellen", "bauen", "implementieren", "entwerfen", "entwickeln", "konstruieren", "generieren", "bereitstellen", "konfigurieren", "einrichten",
81
+ ],
82
+ constraintIndicators: [
83
+ "under", "at most", "at least", "within", "no more than", "o(",
84
+ "maximum", "minimum", "limit", "budget",
85
+ "不超过", "至少", "最多", "在内", "最大", "最小", "限制", "预算",
86
+ "以下", "最大", "最小", "制限", "予算",
87
+ "не более", "не менее", "как минимум", "в пределах", "максимум", "минимум", "ограничение", "бюджет",
88
+ "höchstens", "mindestens", "innerhalb", "nicht mehr als", "maximal", "minimal", "grenze", "budget",
89
+ ],
90
+ outputFormatKeywords: [
91
+ "json", "yaml", "xml", "table", "csv", "markdown", "schema",
92
+ "format as", "structured",
93
+ "表格", "格式化为", "结构化",
94
+ "テーブル", "フォーマット", "構造化",
95
+ "таблица", "форматировать как", "структурированный",
96
+ "tabelle", "formatieren als", "strukturiert",
97
+ ],
98
+ referenceKeywords: [
99
+ "above", "below", "previous", "following", "the docs", "the api",
100
+ "the code", "earlier", "attached",
101
+ "上面", "下面", "之前", "接下来", "文档", "代码", "附件",
102
+ "上記", "下記", "前の", "次の", "ドキュメント", "コード",
103
+ "выше", "ниже", "предыдущий", "следующий", "документация", "код", "ранее", "вложение",
104
+ "oben", "unten", "vorherige", "folgende", "dokumentation", "der code", "früher", "anhang",
105
+ ],
106
+ negationKeywords: [
107
+ "don't", "do not", "avoid", "never", "without", "except", "exclude", "no longer",
108
+ "不要", "避免", "从不", "没有", "除了", "排除",
109
+ "しないで", "避ける", "決して", "なしで", "除く",
110
+ "не делай", "не надо", "нельзя", "избегать", "никогда", "без", "кроме", "исключить", "больше не",
111
+ "nicht", "vermeide", "niemals", "ohne", "außer", "ausschließen", "nicht mehr",
112
+ ],
113
+ domainSpecificKeywords: [
114
+ "quantum", "fpga", "vlsi", "risc-v", "asic", "photonics", "genomics",
115
+ "proteomics", "topological", "homomorphic", "zero-knowledge", "lattice-based",
116
+ "量子", "光子学", "基因组学", "蛋白质组学", "拓扑", "同态", "零知识", "格密码",
117
+ "量子", "フォトニクス", "ゲノミクス", "トポロジカル",
118
+ "квантовый", "фотоника", "геномика", "протеомика", "топологический", "гомоморфный", "с нулевым разглашением", "на основе решёток",
119
+ "quanten", "photonik", "genomik", "proteomik", "topologisch", "homomorph", "zero-knowledge", "gitterbasiert",
120
+ ],
121
+
122
+ agenticTaskKeywords: [
123
+ "read file", "read the file", "look at", "check the", "open the",
124
+ "edit", "modify", "update the", "change the", "write to", "create file",
125
+ "execute", "deploy", "install", "npm", "pip", "compile",
126
+ "after that", "and also", "once done", "step 1", "step 2",
127
+ "fix", "debug", "until it works", "keep trying", "iterate",
128
+ "make sure", "verify", "confirm",
129
+ "读取文件", "查看", "打开", "编辑", "修改", "更新", "创建", "执行",
130
+ "部署", "安装", "第一步", "第二步", "修复", "调试", "直到", "确认", "验证",
131
+ ],
132
+
133
+ // Dimension weights (sum ≈ 1.0)
134
+ dimensionWeights: {
135
+ tokenCount: 0.04,
136
+ codePresence: 0.12,
137
+ reasoningMarkers: 0.25,
138
+ technicalTerms: 0.18,
139
+ creativeMarkers: 0.05,
140
+ simpleIndicators: 0.10,
141
+ multiStepPatterns: 0.12,
142
+ questionComplexity: 0.05,
143
+ imperativeVerbs: 0.06,
144
+ constraintCount: 0.04,
145
+ outputFormat: 0.03,
146
+ referenceComplexity: 0.02,
147
+ negationComplexity: 0.01,
148
+ domainSpecificity: 0.12,
149
+ agenticTask: 0.04,
150
+ },
151
+
152
+ tierBoundaries: {
153
+ simpleMedium: 0.0,
154
+ mediumComplex: 0.03,
155
+ complexReasoning: 0.15,
156
+ },
157
+
158
+ confidenceSteepness: 8,
159
+ confidenceThreshold: 0.50,
160
+ },
161
+
162
+ // ─── TIER → MODEL MAPPING (YOUR API KEYS) ───
163
+ // These use model IDs as configured in your openclaw.json providers.
164
+ // Format: "provider/model-id" matching your openclaw.json config.
165
+
166
+ tiers: {
167
+ SIMPLE: {
168
+ primary: "kimi-coding/kimi-for-coding",
169
+ fallback: ["anthropic/claude-haiku-4-5"],
170
+ },
171
+ MEDIUM: {
172
+ primary: "anthropic/claude-sonnet-4-5",
173
+ fallback: ["anthropic/claude-opus-4-6"],
174
+ },
175
+ COMPLEX: {
176
+ primary: "anthropic/claude-opus-4-6",
177
+ fallback: ["anthropic/claude-haiku-4-5"],
178
+ },
179
+ REASONING: {
180
+ primary: "anthropic/claude-opus-4-6",
181
+ fallback: ["anthropic/claude-haiku-4-5"],
182
+ },
183
+ },
184
+
185
+ // Agentic tier configs — models good at multi-step autonomous tasks
186
+ agenticTiers: {
187
+ SIMPLE: {
188
+ primary: "kimi-coding/kimi-for-coding",
189
+ fallback: ["anthropic/claude-haiku-4-5"],
190
+ },
191
+ MEDIUM: {
192
+ primary: "anthropic/claude-sonnet-4-5",
193
+ fallback: ["anthropic/claude-opus-4-6"],
194
+ },
195
+ COMPLEX: {
196
+ primary: "anthropic/claude-opus-4-6",
197
+ fallback: ["anthropic/claude-haiku-4-5"],
198
+ },
199
+ REASONING: {
200
+ primary: "anthropic/claude-opus-4-6",
201
+ fallback: ["anthropic/claude-haiku-4-5"],
202
+ },
203
+ },
204
+
205
+ overrides: {
206
+ maxTokensForceComplex: 100_000,
207
+ structuredOutputMinTier: "MEDIUM",
208
+ ambiguousDefaultTier: "MEDIUM",
209
+ agenticMode: false,
210
+ },
211
+ };
212
+
213
+
214
+ /**
215
+ * Get the effective routing config, merging external config overrides.
216
+ * External config can override: tiers, agenticTiers, tierBoundaries.
217
+ * Scoring weights and keywords remain as coded defaults (advanced users edit source).
218
+ */
219
+ export function getRoutingConfig(): RoutingConfig {
220
+ const extCfg = getConfig();
221
+ const config = { ...DEFAULT_ROUTING_CONFIG };
222
+
223
+ // Override tiers from external config
224
+ if (extCfg.tiers) {
225
+ config.tiers = extCfg.tiers as RoutingConfig["tiers"];
226
+ }
227
+
228
+ // Override agentic tiers
229
+ if (extCfg.agenticTiers) {
230
+ config.agenticTiers = extCfg.agenticTiers as RoutingConfig["agenticTiers"];
231
+ }
232
+
233
+ // Override tier boundaries
234
+ if (extCfg.tierBoundaries) {
235
+ config.scoring = {
236
+ ...config.scoring,
237
+ tierBoundaries: extCfg.tierBoundaries,
238
+ };
239
+ }
240
+
241
+ return config;
242
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Smart Router Entry Point
3
+ * Forked from ClawRouter (MIT License). No payment dependencies.
4
+ *
5
+ * Classifies requests and routes to the best model from YOUR configured providers.
6
+ * 100% local — rules-based scoring handles all requests in <1ms.
7
+ */
8
+
9
+ import type { Tier, RoutingDecision, RoutingConfig } from "./types.js";
10
+ import { classifyByRules } from "./rules.js";
11
+ import { selectModel, type ModelPricing } from "./selector.js";
12
+
13
+ export type RouterOptions = {
14
+ config: RoutingConfig;
15
+ modelPricing: Map<string, ModelPricing>;
16
+ };
17
+
18
+ /**
19
+ * Route a request to the best model for the task.
20
+ *
21
+ * 1. Check overrides (large context, structured output)
22
+ * 2. Run rule-based classifier (14 weighted dimensions, <1ms)
23
+ * 3. If ambiguous, default to configurable tier
24
+ * 4. Select model for tier
25
+ * 5. Return RoutingDecision with metadata
26
+ */
27
+ export function route(
28
+ prompt: string,
29
+ systemPrompt: string | undefined,
30
+ maxOutputTokens: number,
31
+ options: RouterOptions,
32
+ ): RoutingDecision {
33
+ const { config, modelPricing } = options;
34
+
35
+ // Separate token counts: user prompt for complexity, total for context limits
36
+ // WHY: System prompts (AGENTS.md, SOUL.md) inflate token count — a "hello" with
37
+ // 10K system prompt shouldn't route to Opus. But total tokens still matter for context.
38
+ const estimatedUserTokens = Math.ceil(prompt.length / 4);
39
+ const estimatedTotalTokens = Math.ceil((`${systemPrompt ?? ""} ${prompt}`).length / 4);
40
+
41
+ // --- Rule-based classification ---
42
+ const ruleResult = classifyByRules(prompt, systemPrompt, estimatedUserTokens, config.scoring);
43
+
44
+ // Determine if agentic tiers should be used
45
+ const agenticScore = ruleResult.agenticScore ?? 0;
46
+ const isAutoAgentic = agenticScore >= 0.69;
47
+ const isExplicitAgentic = config.overrides.agenticMode ?? false;
48
+ const useAgenticTiers = (isAutoAgentic || isExplicitAgentic) && config.agenticTiers != null;
49
+ const tierConfigs = useAgenticTiers ? config.agenticTiers! : config.tiers;
50
+
51
+ // --- Override: large context → force COMPLEX ---
52
+ if (estimatedTotalTokens > config.overrides.maxTokensForceComplex) {
53
+ return selectModel(
54
+ "COMPLEX",
55
+ 0.95,
56
+ "rules",
57
+ `Input exceeds ${config.overrides.maxTokensForceComplex} tokens${useAgenticTiers ? " | agentic" : ""}`,
58
+ tierConfigs,
59
+ modelPricing,
60
+ estimatedTotalTokens,
61
+ maxOutputTokens,
62
+ );
63
+ }
64
+
65
+ // Structured output detection
66
+ // Only check user prompt for structured output request (system prompts often mention "json")
67
+ const hasStructuredOutput = /json|structured|schema/i.test(prompt);
68
+
69
+ let tier: Tier;
70
+ let confidence: number;
71
+ const method: "rules" | "llm" = "rules";
72
+ let reasoning = `score=${ruleResult.score.toFixed(2)} | ${ruleResult.signals.join(", ")}`;
73
+
74
+ if (ruleResult.tier !== null) {
75
+ tier = ruleResult.tier;
76
+ confidence = ruleResult.confidence;
77
+ } else {
78
+ tier = config.overrides.ambiguousDefaultTier;
79
+ confidence = 0.5;
80
+ reasoning += ` | ambiguous -> default: ${tier}`;
81
+ }
82
+
83
+ // Apply structured output minimum tier
84
+ if (hasStructuredOutput) {
85
+ const tierRank: Record<Tier, number> = { SIMPLE: 0, MEDIUM: 1, COMPLEX: 2, REASONING: 3 };
86
+ const minTier = config.overrides.structuredOutputMinTier;
87
+ if (tierRank[tier] < tierRank[minTier]) {
88
+ reasoning += ` | upgraded to ${minTier} (structured output)`;
89
+ tier = minTier;
90
+ }
91
+ }
92
+
93
+ if (isAutoAgentic) {
94
+ reasoning += " | auto-agentic";
95
+ } else if (isExplicitAgentic) {
96
+ reasoning += " | agentic";
97
+ }
98
+
99
+ return selectModel(
100
+ tier,
101
+ confidence,
102
+ method,
103
+ reasoning,
104
+ tierConfigs,
105
+ modelPricing,
106
+ estimatedTotalTokens,
107
+ maxOutputTokens,
108
+ );
109
+ }
110
+
111
+ export { getFallbackChain, getFallbackChainFiltered, calculateModelCost } from "./selector.js";
112
+ export { DEFAULT_ROUTING_CONFIG } from "./config.js";
113
+ export type { RoutingDecision, Tier, RoutingConfig } from "./types.js";
114
+ export type { ModelPricing } from "./selector.js";
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Rule-Based Classifier (v2 — Weighted Scoring)
3
+ * Forked from ClawRouter (MIT License). No payment dependencies.
4
+ *
5
+ * Scores a request across 14 weighted dimensions and maps the aggregate
6
+ * score to a tier using configurable boundaries. Confidence is calibrated
7
+ * via sigmoid — low confidence triggers fallback to default tier.
8
+ *
9
+ * Handles 70-80% of requests in < 1ms with zero cost.
10
+ */
11
+
12
+ import type { Tier, ScoringResult, ScoringConfig } from "./types.js";
13
+
14
+ type DimensionScore = { name: string; score: number; signal: string | null };
15
+
16
+ // ─── Dimension Scorers ───
17
+
18
+ function scoreTokenCount(
19
+ estimatedTokens: number,
20
+ thresholds: { simple: number; complex: number },
21
+ ): DimensionScore {
22
+ if (estimatedTokens < thresholds.simple) {
23
+ return { name: "tokenCount", score: -1.0, signal: `short (${estimatedTokens} tokens)` };
24
+ }
25
+ if (estimatedTokens > thresholds.complex) {
26
+ return { name: "tokenCount", score: 1.0, signal: `long (${estimatedTokens} tokens)` };
27
+ }
28
+ return { name: "tokenCount", score: 0, signal: null };
29
+ }
30
+
31
+ function scoreKeywordMatch(
32
+ text: string,
33
+ keywords: string[],
34
+ name: string,
35
+ signalLabel: string,
36
+ thresholds: { low: number; high: number },
37
+ scores: { none: number; low: number; high: number },
38
+ ): DimensionScore {
39
+ const matches = keywords.filter((kw) => text.includes(kw.toLowerCase()));
40
+ if (matches.length >= thresholds.high) {
41
+ return {
42
+ name,
43
+ score: scores.high,
44
+ signal: `${signalLabel} (${matches.slice(0, 3).join(", ")})`,
45
+ };
46
+ }
47
+ if (matches.length >= thresholds.low) {
48
+ return {
49
+ name,
50
+ score: scores.low,
51
+ signal: `${signalLabel} (${matches.slice(0, 3).join(", ")})`,
52
+ };
53
+ }
54
+ return { name, score: scores.none, signal: null };
55
+ }
56
+
57
+ function scoreMultiStep(text: string): DimensionScore {
58
+ const patterns = [/first.*then/i, /step \d/i, /\d\.\s/];
59
+ const hits = patterns.filter((p) => p.test(text));
60
+ if (hits.length > 0) {
61
+ return { name: "multiStepPatterns", score: 0.5, signal: "multi-step" };
62
+ }
63
+ return { name: "multiStepPatterns", score: 0, signal: null };
64
+ }
65
+
66
+ function scoreQuestionComplexity(prompt: string): DimensionScore {
67
+ const count = (prompt.match(/\?/g) || []).length;
68
+ if (count > 3) {
69
+ return { name: "questionComplexity", score: 0.5, signal: `${count} questions` };
70
+ }
71
+ return { name: "questionComplexity", score: 0, signal: null };
72
+ }
73
+
74
+ function scoreAgenticTask(
75
+ text: string,
76
+ keywords: string[],
77
+ ): { dimensionScore: DimensionScore; agenticScore: number } {
78
+ let matchCount = 0;
79
+ const signals: string[] = [];
80
+
81
+ for (const keyword of keywords) {
82
+ if (text.includes(keyword.toLowerCase())) {
83
+ matchCount++;
84
+ if (signals.length < 3) {
85
+ signals.push(keyword);
86
+ }
87
+ }
88
+ }
89
+
90
+ if (matchCount >= 4) {
91
+ return {
92
+ dimensionScore: {
93
+ name: "agenticTask",
94
+ score: 1.0,
95
+ signal: `agentic (${signals.join(", ")})`,
96
+ },
97
+ agenticScore: 1.0,
98
+ };
99
+ } else if (matchCount >= 3) {
100
+ return {
101
+ dimensionScore: {
102
+ name: "agenticTask",
103
+ score: 0.6,
104
+ signal: `agentic (${signals.join(", ")})`,
105
+ },
106
+ agenticScore: 0.6,
107
+ };
108
+ } else if (matchCount >= 1) {
109
+ return {
110
+ dimensionScore: {
111
+ name: "agenticTask",
112
+ score: 0.2,
113
+ signal: `agentic-light (${signals.join(", ")})`,
114
+ },
115
+ agenticScore: 0.2,
116
+ };
117
+ }
118
+
119
+ return {
120
+ dimensionScore: { name: "agenticTask", score: 0, signal: null },
121
+ agenticScore: 0,
122
+ };
123
+ }
124
+
125
+ // ─── Main Classifier ───
126
+
127
+ export function classifyByRules(
128
+ prompt: string,
129
+ systemPrompt: string | undefined,
130
+ estimatedTokens: number,
131
+ config: ScoringConfig,
132
+ ): ScoringResult {
133
+ const text = `${systemPrompt ?? ""} ${prompt}`.toLowerCase();
134
+ const userText = prompt.toLowerCase();
135
+
136
+ const dimensions: DimensionScore[] = [
137
+ scoreTokenCount(estimatedTokens, config.tokenCountThresholds),
138
+ scoreKeywordMatch(
139
+ text,
140
+ config.codeKeywords,
141
+ "codePresence",
142
+ "code",
143
+ { low: 1, high: 2 },
144
+ { none: 0, low: 0.5, high: 1.0 },
145
+ ),
146
+ scoreKeywordMatch(
147
+ userText,
148
+ config.reasoningKeywords,
149
+ "reasoningMarkers",
150
+ "reasoning",
151
+ { low: 1, high: 2 },
152
+ { none: 0, low: 0.7, high: 1.0 },
153
+ ),
154
+ scoreKeywordMatch(
155
+ text,
156
+ config.technicalKeywords,
157
+ "technicalTerms",
158
+ "technical",
159
+ { low: 2, high: 4 },
160
+ { none: 0, low: 0.5, high: 1.0 },
161
+ ),
162
+ scoreKeywordMatch(
163
+ text,
164
+ config.creativeKeywords,
165
+ "creativeMarkers",
166
+ "creative",
167
+ { low: 1, high: 2 },
168
+ { none: 0, low: 0.5, high: 0.7 },
169
+ ),
170
+ scoreKeywordMatch(
171
+ text,
172
+ config.simpleKeywords,
173
+ "simpleIndicators",
174
+ "simple",
175
+ { low: 1, high: 2 },
176
+ { none: 0, low: -1.0, high: -1.0 },
177
+ ),
178
+ scoreMultiStep(text),
179
+ scoreQuestionComplexity(prompt),
180
+
181
+ scoreKeywordMatch(
182
+ text,
183
+ config.imperativeVerbs,
184
+ "imperativeVerbs",
185
+ "imperative",
186
+ { low: 1, high: 2 },
187
+ { none: 0, low: 0.3, high: 0.5 },
188
+ ),
189
+ scoreKeywordMatch(
190
+ text,
191
+ config.constraintIndicators,
192
+ "constraintCount",
193
+ "constraints",
194
+ { low: 1, high: 3 },
195
+ { none: 0, low: 0.3, high: 0.7 },
196
+ ),
197
+ scoreKeywordMatch(
198
+ text,
199
+ config.outputFormatKeywords,
200
+ "outputFormat",
201
+ "format",
202
+ { low: 1, high: 2 },
203
+ { none: 0, low: 0.4, high: 0.7 },
204
+ ),
205
+ scoreKeywordMatch(
206
+ text,
207
+ config.referenceKeywords,
208
+ "referenceComplexity",
209
+ "references",
210
+ { low: 1, high: 2 },
211
+ { none: 0, low: 0.3, high: 0.5 },
212
+ ),
213
+ scoreKeywordMatch(
214
+ text,
215
+ config.negationKeywords,
216
+ "negationComplexity",
217
+ "negation",
218
+ { low: 2, high: 3 },
219
+ { none: 0, low: 0.3, high: 0.5 },
220
+ ),
221
+ scoreKeywordMatch(
222
+ text,
223
+ config.domainSpecificKeywords,
224
+ "domainSpecificity",
225
+ "domain-specific",
226
+ { low: 1, high: 2 },
227
+ { none: 0, low: 0.5, high: 0.8 },
228
+ ),
229
+ ];
230
+
231
+ const agenticResult = scoreAgenticTask(text, config.agenticTaskKeywords);
232
+ dimensions.push(agenticResult.dimensionScore);
233
+ const agenticScore = agenticResult.agenticScore;
234
+
235
+ const signals = dimensions.filter((d) => d.signal !== null).map((d) => d.signal!);
236
+
237
+ const weights = config.dimensionWeights;
238
+ let weightedScore = 0;
239
+ for (const d of dimensions) {
240
+ const w = weights[d.name] ?? 0;
241
+ weightedScore += d.score * w;
242
+ }
243
+
244
+ const reasoningMatches = config.reasoningKeywords.filter((kw) =>
245
+ userText.includes(kw.toLowerCase()),
246
+ );
247
+
248
+ // Direct reasoning override: 2+ reasoning markers = high confidence REASONING
249
+ if (reasoningMatches.length >= 2) {
250
+ const confidence = calibrateConfidence(
251
+ Math.max(weightedScore, 0.3),
252
+ config.confidenceSteepness,
253
+ );
254
+ return {
255
+ score: weightedScore,
256
+ tier: "REASONING",
257
+ confidence: Math.max(confidence, 0.85),
258
+ signals,
259
+ agenticScore,
260
+ };
261
+ }
262
+
263
+ const { simpleMedium, mediumComplex, complexReasoning } = config.tierBoundaries;
264
+ let tier: Tier;
265
+ let distanceFromBoundary: number;
266
+
267
+ if (weightedScore < simpleMedium) {
268
+ tier = "SIMPLE";
269
+ distanceFromBoundary = simpleMedium - weightedScore;
270
+ } else if (weightedScore < mediumComplex) {
271
+ tier = "MEDIUM";
272
+ distanceFromBoundary = Math.min(weightedScore - simpleMedium, mediumComplex - weightedScore);
273
+ } else if (weightedScore < complexReasoning) {
274
+ tier = "COMPLEX";
275
+ distanceFromBoundary = Math.min(
276
+ weightedScore - mediumComplex,
277
+ complexReasoning - weightedScore,
278
+ );
279
+ } else {
280
+ tier = "REASONING";
281
+ distanceFromBoundary = weightedScore - complexReasoning;
282
+ }
283
+
284
+ const confidence = calibrateConfidence(distanceFromBoundary, config.confidenceSteepness);
285
+
286
+ if (confidence < config.confidenceThreshold) {
287
+ return { score: weightedScore, tier: null, confidence, signals, agenticScore };
288
+ }
289
+
290
+ return { score: weightedScore, tier, confidence, signals, agenticScore };
291
+ }
292
+
293
+ /**
294
+ * Sigmoid confidence calibration.
295
+ * Maps distance from tier boundary to [0.5, 1.0] confidence range.
296
+ */
297
+ function calibrateConfidence(distance: number, steepness: number): number {
298
+ return 1 / (1 + Math.exp(-steepness * distance));
299
+ }