fix(devin): treat Devin CLI model ids as literal — never strip or synthesize effort suffixes (#12492)

The Devin CLI providers (devin-cli, devin-cli-agentic, devin-desktop; aliases
dv/dva) serve a catalog whose model ids EMBED the reasoning tier:
claude-opus-5-low, claude-opus-5-medium, … and gpt-5-6-sol-max/-low are
distinct upstream models (see registry/devin/catalog.ts).

applyClaudeEffortVariant stripped the trailing -{low,medium,high,xhigh,max}
from any id whose base is a known Claude model, regardless of provider. For
Devin lanes this dispatched a base id that does not exist upstream, e.g.

  dva/claude-opus-5-low  ->  claude-opus-5  ->  400
  'Model is not present in the current Devin catalog: claude-opus-5'

Only accidental double-suffixed ids (dva/claude-opus-5-max-low) survived,
because stripping the outer -low left the real claude-opus-5-max. Symmetrically,
the catalog synthesized -<level> variants on top of tier-embedded ids,
advertising phantom ids (dva/gpt-5-6-sol-max-low, dva/kimi-k3-*) that 400 when
called.

Three gates now treat Devin ids as literal:
- applyClaudeEffortVariant: early return for Devin providers (ids/aliases)
- appendClaudeEffortVariants: no -<level> variants for devin-prefixed ids
- appendSyncedEffortVariants: isSkippedEffortProvider now covers Devin
  providers (they own their suffix mechanism — the tier IS the id)

Validated live on a self-hosted v3.8.51 deployment: dva/claude-opus-5-low,
dva/claude-5-fable-low and the whole tier-embedded catalog now dispatch; the
phantom variant ids disappear from /v1/models. Claude-lane stripping
(claude/cc, e.g. cc/claude-opus-5-high -> claude-opus-5 + reasoning_effort) is
unchanged and covered by existing + new characterization tests.

Co-authored-by: Neuron Mr White <whiteneuron@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Mr White
2026-09-18 23:21:36 +08:00
committed by GitHub
parent b36c81d3f0
commit 5ba3eee2b0
7 changed files with 200 additions and 1 deletions

View File

@@ -0,0 +1 @@
- **fix(devin):** treat Devin CLI model ids as literal — never strip or synthesize effort suffixes ([#12492](https://github.com/diegosouzapw/OmniRoute/pull/12492) — thanks @Neuron-Mr-White)

View File

@@ -16,6 +16,7 @@ import { splitClaudeEffortSuffix } from "../../config/providerModels.ts";
import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts";
import { FORMATS } from "../../translator/formats.ts";
import { isKnownClaudeEffortBaseModel } from "../../utils/claudeEffortVariants.ts";
import { isDevinLiteralModelIdProvider } from "../../utils/devinLiteralModelIds.ts";
/**
* True when the client already supplied an explicit reasoning effort (top-level reasoning_effort,
@@ -52,6 +53,14 @@ export function applyClaudeEffortVariant(opts: {
let effectiveModel = opts.effectiveModel;
let log: string | null = null;
// Devin CLI catalogs embed the effort tier in the model id itself
// (`claude-opus-5-low` is a distinct upstream model). Stripping the suffix
// would dispatch a base id that does not exist upstream, so keep the id
// literal for these providers regardless of the Claude-family name.
if (isDevinLiteralModelIdProvider(provider)) {
return { effectiveModel, log: null };
}
if (typeof effectiveModel === "string") {
const { baseModel, effort } = splitClaudeEffortSuffix(effectiveModel);
const isDirectClaudeLane = provider === "claude" || isClaudeCodeCompatibleProvider(provider);

View File

@@ -30,6 +30,7 @@
*/
import { getModelSpec } from "@/shared/constants/modelSpecs";
import { supportsXHighEffort } from "../config/providerModels.ts";
import { isDevinLiteralModelIdProvider } from "./devinLiteralModelIds.ts";
/** Base reasoning-effort levels advertised for every effort-capable Claude model. */
export const CLAUDE_EFFORT_VARIANT_LEVELS = ["low", "medium", "high"] as const;
@@ -94,6 +95,16 @@ export function shouldExposeClaudeEffortVariants(
if (id.startsWith(NO_THINKING_PREFIX)) return false;
if (CLAUDE_EFFORT_SUFFIX_RE.test(id)) return false;
// Devin CLI catalogs (devin-cli / devin-cli-agentic / devin-desktop, aliases
// dv / dva) embed the tier in the model id itself — every tier is already a
// distinct catalog id, and the gateway keeps those ids literal (see
// devinLiteralModelIds.ts). Synthesizing `-<level>` variants on top of them
// would advertise unroutable phantom ids like `dva/claude-opus-5-max-low`.
const providerSlash = id.indexOf("/");
if (providerSlash > 0 && isDevinLiteralModelIdProvider(id.slice(0, providerSlash))) {
return false;
}
const name = bareModelName(id);
return isKnownClaudeEffortBaseModel(name);
}

View File

@@ -0,0 +1,40 @@
/**
* Devin CLI providers whose upstream catalog embeds the reasoning tier IN the
* model id itself: `claude-opus-5-low`, `claude-opus-5-medium`, … and
* `gpt-5-6-sol-max` / `gpt-5-6-sol-low` are distinct upstream models
* (see `config/providers/registry/devin/catalog.ts`). For these providers a
* trailing `-{effort}` suffix is NOT a client-side effort variant:
*
* - stripping it (`applyClaudeEffortVariant`) would dispatch a base id that
* does not exist upstream — e.g. `dva/claude-opus-5-low` became
* `claude-opus-5` and the executor rejected it with
* "Model is not present in the current Devin catalog";
* - synthesizing variants on top of tier-embedded ids produces phantom ids
* (`claude-opus-5-max-low`) that cannot route once the strip is fixed.
*
* Ids here cover the provider id and its routing alias, so both canonical and
* alias-prefixed qualified model ids are recognized.
*/
const DEVIN_LITERAL_MODEL_ID_PROVIDERS = new Set([
"devin-cli",
"devin-cli-agentic",
"devin-desktop",
]);
const DEVIN_LITERAL_MODEL_ID_ALIASES = new Set(["dv", "dva"]);
function bareProviderToken(value: string): string {
const slash = value.indexOf("/");
return slash >= 0 ? value.slice(0, slash) : value;
}
/**
* True when `provider` (a provider id or alias, optionally `provider/model`
* qualified) serves a Devin catalog whose model ids embed the effort tier and
* must therefore be treated as literal ids.
*/
export function isDevinLiteralModelIdProvider(provider: string | null | undefined): boolean {
if (typeof provider !== "string" || provider.length === 0) return false;
const token = bareProviderToken(provider);
return DEVIN_LITERAL_MODEL_ID_PROVIDERS.has(token) || DEVIN_LITERAL_MODEL_ID_ALIASES.has(token);
}

View File

@@ -27,6 +27,7 @@
* model that legitimately ends in an effort-like token (e.g. a model named "...-high").
*/
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization.ts";
import { isDevinLiteralModelIdProvider } from "./devinLiteralModelIds.ts";
/** Provider ids with dedicated `-{effort}` aliases — never synthesize another suffix layer. */
export const SYNCED_EFFORT_SKIP_PROVIDERS = new Set(["codex", "glm", "glm-cn", "glmt"]);
@@ -37,7 +38,10 @@ const SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES = ["kimi"];
export function isSkippedEffortProvider(ownedBy: string): boolean {
return (
SYNCED_EFFORT_SKIP_PROVIDERS.has(ownedBy) ||
SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES.some((prefix) => ownedBy.startsWith(prefix))
SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES.some((prefix) => ownedBy.startsWith(prefix)) ||
// Devin CLI catalogs (devin-cli / devin-cli-agentic / devin-desktop, aliases
// dv / dva) embed the tier in the id itself — no variant layer on top.
isDevinLiteralModelIdProvider(ownedBy)
);
}

View File

@@ -215,3 +215,62 @@ test("no-think alias's explicit reasoning_effort:none is not overwritten by a st
assert.equal(body.model, "claude-sonnet-5");
assert.equal(body.reasoning_effort, "none");
});
// ── Devin CLI providers: model ids embed the tier and must stay literal ─────────
// Regression for `dva/claude-opus-5-low` → stripped to `claude-opus-5` → executor
// rejected "Model is not present in the current Devin catalog" (400). The Devin
// catalog (devin/catalog.ts) has one id per tier; only the accidental
// double-suffixed ids (`claude-opus-5-max-low`) survived the old behavior.
test("devin-cli-agentic provider keeps a tier-embedded id literal (no strip, no body mutation)", () => {
const body: Record<string, unknown> = { model: "claude-opus-5-low", messages: [] };
const r = applyClaudeEffortVariant({
provider: "devin-cli-agentic",
effectiveModel: "claude-opus-5-low",
body,
sourceFormat: FORMATS.OPENAI,
});
assert.equal(r.effectiveModel, "claude-opus-5-low");
assert.equal(body.model, "claude-opus-5-low");
assert.equal(body.reasoning_effort, undefined);
assert.equal(r.log, null);
});
test("devin provider alias (dva) is covered too", () => {
const body: Record<string, unknown> = { model: "claude-opus-5-medium", messages: [] };
const r = applyClaudeEffortVariant({
provider: "dva",
effectiveModel: "claude-opus-5-medium",
body,
sourceFormat: FORMATS.OPENAI,
});
assert.equal(r.effectiveModel, "claude-opus-5-medium");
assert.equal(body.model, "claude-opus-5-medium");
assert.equal(r.log, null);
});
test("devin-cli (text bridge) and devin-desktop keep literal ids as well", () => {
for (const provider of ["devin-cli", "devin-desktop", "dv"]) {
const body: Record<string, unknown> = { model: "claude-sonnet-5-low", messages: [] };
const r = applyClaudeEffortVariant({
provider,
effectiveModel: "claude-sonnet-5-low",
body,
sourceFormat: FORMATS.OPENAI,
});
assert.equal(r.effectiveModel, "claude-sonnet-5-low", provider);
assert.equal(body.reasoning_effort, undefined, provider);
}
});
test("a claude-lane strip still happens for the same model name (control)", () => {
const body: Record<string, unknown> = { model: "claude-opus-5-low", messages: [] };
const r = applyClaudeEffortVariant({
provider: "claude",
effectiveModel: "claude-opus-5-low",
body,
sourceFormat: FORMATS.OPENAI,
});
assert.equal(r.effectiveModel, "claude-opus-5");
assert.equal(body.reasoning_effort, "low");
});

View File

@@ -0,0 +1,75 @@
// tests/unit/devin-literal-effort-ids.test.ts
// Devin CLI providers (devin-cli / devin-cli-agentic / devin-desktop, aliases dv / dva)
// serve catalogs whose model ids EMBED the reasoning tier (`claude-opus-5-low`,
// `gpt-5-6-sol-max` are distinct upstream models — see
// open-sse/config/providers/registry/devin/catalog.ts). Locks the three gates that
// must treat those ids as literal:
// 1. applyClaudeEffortVariant never strips the suffix for devin providers;
// 2. appendClaudeEffortVariants never synthesizes `-<level>` variants on top of
// them (no phantom `dva/claude-opus-5-max-low` ids in /v1/models);
// 3. appendSyncedEffortVariants / isSkippedEffortProvider treat devin providers
// as owning their own suffix mechanism (no second variant layer).
import { test } from "node:test";
import assert from "node:assert/strict";
import { isDevinLiteralModelIdProvider } from "../../open-sse/utils/devinLiteralModelIds.ts";
import { appendClaudeEffortVariants } from "../../open-sse/utils/claudeEffortVariants.ts";
import {
appendSyncedEffortVariants,
isSkippedEffortProvider,
} from "../../open-sse/utils/syncedEffortVariants.ts";
test("isDevinLiteralModelIdProvider matches ids, aliases, and qualified prefixes", () => {
for (const hit of [
"devin-cli",
"devin-cli-agentic",
"devin-desktop",
"dv",
"dva",
"dva/claude-opus-5-low",
"devin-cli-agentic/claude-opus-5-max",
]) {
assert.equal(isDevinLiteralModelIdProvider(hit), true, hit);
}
for (const miss of ["claude", "cc", "vertex", "", null, undefined, "codex", "deepseek"]) {
assert.equal(
isDevinLiteralModelIdProvider(miss as string | null | undefined),
false,
String(miss)
);
}
});
test("appendClaudeEffortVariants adds no tier variants for devin-prefixed models", () => {
const models = [
{ id: "dva/claude-opus-5-max", root: "claude-opus-5-max" },
{ id: "devin-cli-agentic/claude-5-fable-max", root: "claude-5-fable-max" },
];
const out = appendClaudeEffortVariants(models);
assert.equal(out.length, models.length);
assert.deepEqual(
out.map((m) => m.id),
["dva/claude-opus-5-max", "devin-cli-agentic/claude-5-fable-max"]
);
});
test("appendSyncedEffortVariants adds no tier variants for devin-owned models", () => {
const models = [
{
id: "dva/gpt-5-6-sol-max",
owned_by: "devin-cli-agentic",
capabilities: { effort_tiers: ["low", "medium", "high", "xhigh"] },
},
];
const out = appendSyncedEffortVariants(models as never);
assert.equal(out.length, 1);
assert.equal(out[0].id, "dva/gpt-5-6-sol-max");
});
test("isSkippedEffortProvider covers devin providers and aliases", () => {
for (const provider of ["devin-cli", "devin-cli-agentic", "devin-desktop", "dva", "dv"]) {
assert.equal(isSkippedEffortProvider(provider), true, provider);
}
assert.equal(isSkippedEffortProvider("claude"), false);
assert.equal(isSkippedEffortProvider("codex"), true); // pre-existing skip stays
});