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.
@@ -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.37.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.37.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 try a different
1075
- model in the **same** tier (bounded, like a 5xx failover) and only then step
1076
- up; structural signals (`malformed_tool_args`, `repeat_tool_call`, a truncated
1077
- tool call) escalate a tier directly. A client that hangs up after the finish
1078
- event has already arrived is treated as a completed turn, not an error.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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
  }
@@ -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 }
@@ -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 {
@@ -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
  });