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); + } + }); +});