From fc706f6d66eea218425af9dae0237c8571ff9e00 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 11:56:33 +0900 Subject: [PATCH] fix(devin): apply explicit SWE-2 effort before model suffix Carry #4420 from 6a456fb2af306a2d30a36e2f884c75318d3dd18b onto 7ca00ffe7. Cognition spells SWE-2 effort as the model id rather than a separate field, and resolveWireModelUid returned early whenever the id already carried an effort suffix. Asking for swe-2-high at medium therefore stayed swe-2-high and the caller was silently ignored. Resolve an explicit SWE-2 effort to the native medium/high/max UID before that shortcut runs. none, off, minimal and low select Medium, since SWE-2 has no lane below it and rounding down to nothing would quietly disable its reasoning; xhigh, ultra and max select Max. Omitted or unknown effort preserves the variant, and other model families keep their existing suffix precedence. Two deliberate differences from the source PR. The effort map is a named table instead of an inline branch, because EFFORT_SUFFIXES does not carry ultra, off or minimal and the two would drift apart silently. And matching is case-insensitive, so a caller sending HIGH reaches the same lane as high. The resolver is exposed to tests through resolveWireModelUidForTests rather than by exporting its bare name, mirroring sanitizeToolDescriptionForCognitionForTests: it is an async helper that reaches the catalog and does not belong in the adapter public API. Ownership is recorded in structure/adapters/registry.md and the user-facing adapters page. The source PR also pasted the same sentence into six unrelated structure files; those hunks are omitted, and the structure gate checks path mentions rather than behavioral prose, so the omission is safe. This is the post-ACP landing of closed #4416 and does not restore ACP. Local product tests, typecheck, build and install: NOT RUN. Hosted exact-head CI on this PR is the merge proof. Co-authored-by: Smartnewb <159137930+Smartnewb@users.noreply.github.com> --- .../src/content/docs/reference/adapters.md | 7 +++ src/adapters/devin.ts | 45 +++++++++++++++++++ structure/adapters/registry.md | 7 +++ tests/providers/devin-adapter.test.ts | 37 ++++++++++++++- 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 8f524e39c1..7acc2dc7f7 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -451,6 +451,13 @@ Login opens Auth0 browser sign-in, then exchanges the Firebase ID token via - Experimental unofficial bridge; not shown in the dashboard preset by default. See the [provider guide](/guides/providers/) for login instructions. +For SWE-2, an explicit reasoning effort overrides an effort suffix in the model +id. For example, `swe-2-high` with `medium` selects the native `swe-2-medium` UID; +`xhigh`, `ultra`, and `max` select `swe-2-max`. Values below Medium select Medium +and do not disable SWE-2 reasoning. Without an explicit effort, a suffixed model +id is preserved. This applies to both Devin account providers through their +shared adapter; other model families keep their existing suffix precedence. + ## `devin-cli` **Targets:** Cognition's `exa.api_server_pb.ApiServerService/GetChatMessage`, the same Connect diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index ab7abfa0a9..1d7a15f8d6 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -84,6 +84,39 @@ function hasEffortSuffix(modelId: string): boolean { return parts.length > 1 && EFFORT_SUFFIXES.has(parts[parts.length - 1]!); } +/** + * SWE-2 ships exactly three native lanes. Cognition spells them as the model id, + * not as a separate effort field, so an explicit caller effort has to be resolved + * to the UID before the suffix shortcut below accepts whatever the picker sent. + * + * Kept as a named table rather than an inline branch because EFFORT_SUFFIXES does + * not carry `ultra`, `off`, or `minimal`, so the two would drift apart silently. + * Values below Medium select Medium: SWE-2 has no lane under it, and rounding down + * to nothing would quietly disable its reasoning. + */ +const SWE2_EFFORT: Record = { + none: "medium", + off: "medium", + minimal: "medium", + low: "medium", + medium: "medium", + high: "high", + xhigh: "max", + ultra: "max", + max: "max", +}; + +/** + * Resolve an explicit effort onto a SWE-2 lane, or undefined when this is not a + * SWE-2 id or the caller named no usable effort. Undefined leaves every existing + * path untouched, which is what keeps other model families on suffix precedence. + */ +function resolveSwe2Variant(modelId: string, reasoningEffort?: string): string | undefined { + if (!/^swe-2(?:-(?:medium|high|max))?$/.test(modelId)) return undefined; + const mapped = reasoningEffort ? SWE2_EFFORT[reasoningEffort.toLowerCase()] : undefined; + return mapped ? `swe-2-${mapped}` : undefined; +} + /** * Resolve the wire model UID using the live catalog as the source of truth. * Cognition's catalog lists most models with an effort suffix @@ -103,6 +136,11 @@ async function resolveWireModelUid( reasoningEffort?: string, ): Promise { const modelId = normalizeDevinModelId(rawModelId); + // Explicit effort wins over a suffix the picker already baked into the id, so + // `swe-2-high` asked for at `medium` becomes `swe-2-medium` instead of ignoring + // the caller. Runs before the shortcut below, which would otherwise return early. + const swe2 = resolveSwe2Variant(modelId, reasoningEffort); + if (swe2) return swe2; if (hasEffortSuffix(modelId)) return modelId; const catalog = await getCachedCatalog(apiKey, host); if (catalog) { @@ -120,6 +158,13 @@ async function resolveWireModelUid( return `${modelId}-${effort}`; } +/** + * Test seam. The resolver stays module-private because it reaches the catalog; + * exporting it under its bare name would make an async network-touching helper + * part of the adapter public API. Mirrors sanitizeToolDescriptionForCognitionForTests. + */ +export const resolveWireModelUidForTests = resolveWireModelUid; + export class DevinMissingCredentialError extends Error { constructor() { super("Devin live transport requires a Devin API key. Run ocx login devin to sign in with your Cognition/Devin account."); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 2bc849c3cc..d44c3b99ac 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -97,3 +97,10 @@ Live sideband admission and its bounded upstream handshake follow the [runtime c Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached. + +## SWE-2 model effort selection + +`src/adapters/devin.ts` resolves an explicit SWE-2 reasoning effort to the native +medium/high/max UID before accepting a suffix already present in the model id. +Both Devin provider rows share this resolver. Omitted effort preserves an explicit +variant; unrelated model families retain their existing suffix precedence. diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index aa22e0edce..0054629b9e 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createDevinAdapter, mapOcxMessagesToDevin, mapOcxToolsToDevin } from "../../src/adapters/devin"; +import { createDevinAdapter, mapOcxMessagesToDevin, mapOcxToolsToDevin, resolveWireModelUidForTests } from "../../src/adapters/devin"; import { sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; @@ -201,3 +201,38 @@ describe("devin adapter", () => { } }); }); + +describe("SWE-2 wire effort selection", () => { + // Cognition spells SWE-2 effort as the model id, so an explicit effort has to + // beat a suffix the picker already chose. Before this, swe-2-high asked for at + // medium stayed high and the caller was silently ignored. + test.each(["medium", "high", "max"])("an explicit %s effort overrides every SWE-2 variant", async (effort) => { + for (const model of ["swe-2", "swe-2-medium", "swe-2-high", "swe-2-max", "swe-2.high"]) { + expect(await resolveWireModelUidForTests(model, "unused", "unused", effort)).toBe(`swe-2-${effort}`); + } + }); + + test.each([ + ["none", "medium"], ["off", "medium"], ["minimal", "medium"], + ["low", "medium"], ["xhigh", "max"], ["ultra", "max"], + ])("maps %s to the supported SWE-2 %s lane", async (effort, expected) => { + expect(await resolveWireModelUidForTests("swe-2-high", "unused", "unused", effort)).toBe(`swe-2-${expected}`); + }); + + // Case is normalised, which the source contribution did not do: a caller that + // sends HIGH means the same lane as high. + test("effort matching is case-insensitive", async () => { + expect(await resolveWireModelUidForTests("swe-2-medium", "unused", "unused", "HIGH")).toBe("swe-2-high"); + }); + + test("omitted or unknown effort preserves an explicit variant", async () => { + expect(await resolveWireModelUidForTests("swe-2-high", "unused", "unused")).toBe("swe-2-high"); + expect(await resolveWireModelUidForTests("swe-2-max", "unused", "unused", "future-effort")).toBe("swe-2-max"); + }); + + test("other model families keep their existing suffix precedence", async () => { + for (const model of ["claude-opus-5-medium", "gpt-5-6-sol-high", "swe-1-7-high", "swe-20-high"]) { + expect(await resolveWireModelUidForTests(model, "unused", "unused", "max")).toBe(model); + } + }); +});