auto-model-router 0.37.0 → 0.38.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/.omp-plugin/marketplace.json +2 -2
- package/README.md +13 -5
- package/package.json +1 -1
- package/src/router/escalate.ts +11 -0
- package/src/router/types.ts +2 -1
- package/src/server/turn.ts +1 -1
- package/test/escalate.test.ts +25 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.38.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.38.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1071,11 +1071,19 @@ Each task (`coding`, `vision`, `documentation`, `data`, `chat`) is a
|
|
|
1071
1071
|
|
|
1072
1072
|
The model that produced the rejected output never serves the retry, at this
|
|
1073
1073
|
tier or the next. Signals that indict the *provider* rather than the tier —
|
|
1074
|
-
`empty_completion`, `refusal`, and an error finish — first
|
|
1075
|
-
model in the **same** tier (bounded, like a 5xx failover) and
|
|
1076
|
-
up; structural signals (`malformed_tool_args`,
|
|
1077
|
-
tool call) escalate a tier directly. A client
|
|
1078
|
-
event has already arrived is treated as a
|
|
1074
|
+
`empty_completion`, `refusal`, `content_filter`, and an error finish — first
|
|
1075
|
+
try a different model in the **same** tier (bounded, like a 5xx failover) and
|
|
1076
|
+
only then step up; structural signals (`malformed_tool_args`,
|
|
1077
|
+
`repeat_tool_call`, a truncated tool call) escalate a tier directly. A client
|
|
1078
|
+
that hangs up after the finish event has already arrived is treated as a
|
|
1079
|
+
completed turn, not an error.
|
|
1080
|
+
|
|
1081
|
+
A `content_filter` finish (the provider's filter stopping the completion,
|
|
1082
|
+
including Anthropic's `refusal` stop reason, which maps to it) rides the
|
|
1083
|
+
`refusal` trigger: the same failure class — the provider declining the work —
|
|
1084
|
+
so a config that already opts into refusal escalation gets it without a
|
|
1085
|
+
change. A filter finish that produced NO text is an `empty_completion`
|
|
1086
|
+
instead: nothing was filtered, so a plain retry is the fix.
|
|
1079
1087
|
|
|
1080
1088
|
### `hysteresis` — cache-aware model stickiness
|
|
1081
1089
|
|
package/package.json
CHANGED
package/src/router/escalate.ts
CHANGED
|
@@ -165,6 +165,17 @@ export function createProbe(
|
|
|
165
165
|
if (matchesRefusal() && triggers.has("refusal")) {
|
|
166
166
|
return escalate("refusal", "completion opens with a refusal");
|
|
167
167
|
}
|
|
168
|
+
if (finishReason === "content_filter") {
|
|
169
|
+
// The PROVIDER stopped the completion, not the model. Whatever text
|
|
170
|
+
// arrived ends mid-answer, and a different model — one whose filter
|
|
171
|
+
// does not read this content the same way — may serve the turn whole.
|
|
172
|
+
// It rides the `refusal` trigger: the same failure class, the provider
|
|
173
|
+
// declining the work rather than the model. A filter finish with NO
|
|
174
|
+
// text was never generated, so nothing was filtered; that is the
|
|
175
|
+
// empty_completion case above, and a plain retry is the fix.
|
|
176
|
+
if (triggers.has("refusal")) return escalate("content_filter", "the provider's content filter stopped the completion");
|
|
177
|
+
return commit("content_filter finish tolerated; signal disabled");
|
|
178
|
+
}
|
|
168
179
|
if (finishReason === "error" && triggers.has("upstream_error")) {
|
|
169
180
|
return escalate("upstream_error", "upstream reported an error finish");
|
|
170
181
|
}
|
package/src/router/types.ts
CHANGED
|
@@ -329,7 +329,8 @@ export type EscalationSignal =
|
|
|
329
329
|
| "repeat_tool_call"
|
|
330
330
|
| "length_stop"
|
|
331
331
|
| "missing_expected_tool_call"
|
|
332
|
-
| "upstream_error"
|
|
332
|
+
| "upstream_error"
|
|
333
|
+
| "content_filter";
|
|
333
334
|
|
|
334
335
|
export type ProbeVerdict =
|
|
335
336
|
| { action: "commit"; reason: string }
|
package/src/server/turn.ts
CHANGED
|
@@ -54,7 +54,7 @@ const MAX_SAME_TIER_FAILOVERS = 2;
|
|
|
54
54
|
* signals (malformed arguments, a repeated call) still escalate directly — a
|
|
55
55
|
* stronger model is the remedy there.
|
|
56
56
|
*/
|
|
57
|
-
const PROVIDER_SIGNALS: ReadonlySet<string> = new Set(["empty_completion", "refusal", "upstream_error"]);
|
|
57
|
+
const PROVIDER_SIGNALS: ReadonlySet<string> = new Set(["empty_completion", "refusal", "upstream_error", "content_filter"]);
|
|
58
58
|
|
|
59
59
|
/** A client hang-up: the request signal fired, or the transport reported the abort. */
|
|
60
60
|
function isClientAbort(err: unknown, signal: AbortSignal): boolean {
|
package/test/escalate.test.ts
CHANGED
|
@@ -226,4 +226,29 @@ describe("createProbe", () => {
|
|
|
226
226
|
expect(verdict.action).toBe("escalate");
|
|
227
227
|
if (verdict.action === "escalate") expect(verdict.signal).toBe("empty_completion");
|
|
228
228
|
});
|
|
229
|
+
|
|
230
|
+
test("a content_filter finish with text escalates on the refusal trigger", async () => {
|
|
231
|
+
// The PROVIDER stopped the completion mid-answer; another model may serve
|
|
232
|
+
// the turn whole, so this is a provider signal (same-tier failover).
|
|
233
|
+
const p = createProbe(plan({ maxTokens: 1_000 }), req(), ALL_TRIGGERS);
|
|
234
|
+
expect(p.observe(text("The first part of the answer is"))).toBeNull();
|
|
235
|
+
const verdict = p.observe(chunk([{ type: "finish", reason: "content_filter" }]));
|
|
236
|
+
expect(verdict?.action).toBe("escalate");
|
|
237
|
+
if (verdict?.action === "escalate") expect(verdict.signal).toBe("content_filter");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("a content_filter finish with NO text is an empty completion: nothing was filtered", async () => {
|
|
241
|
+
const p = createProbe(plan({ maxTokens: 1_000 }), req(), ALL_TRIGGERS);
|
|
242
|
+
const verdict = p.observe(chunk([{ type: "finish", reason: "content_filter" }]));
|
|
243
|
+
expect(verdict?.action).toBe("escalate");
|
|
244
|
+
if (verdict?.action === "escalate") expect(verdict.signal).toBe("empty_completion");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a content_filter finish commits when the refusal trigger is off", async () => {
|
|
248
|
+
const triggers = new Set([...ALL_TRIGGERS].filter((t) => t !== "refusal"));
|
|
249
|
+
const p = createProbe(plan({ maxTokens: 1_000 }), req(), triggers);
|
|
250
|
+
expect(p.observe(text("The first part of the answer is"))).toBeNull();
|
|
251
|
+
const verdict = p.observe(chunk([{ type: "finish", reason: "content_filter" }]));
|
|
252
|
+
expect(verdict?.action).toBe("commit");
|
|
253
|
+
});
|
|
229
254
|
});
|