From ef3fc5673c034616e17539341e1c31aba64d750f Mon Sep 17 00:00:00 2001 From: Kevin Lo Date: Mon, 14 Sep 2026 01:21:52 +0900 Subject: [PATCH 1/2] feat(models,devin-acp): add swe-2 and opt-in Devin Local routing for gpt-5.6 SWE-2 medium/high/max from docs.devin.ai (credit 6/9/12). Connect deployments read them from the snapshot; Cascade/LS deployments with the CLI enabled use backend=special_agent (same as swe-1.6). gpt-5.6 luna/sol/terra stay on Cascade unless DEVIN_CLI_ENABLED=1. ACP does not invent api_server_url, does not default DEVIN_PERMISSION_MODE, and names itself WindsurfAPI unless DEVIN_ACP_CLIENT_NAME is set. Model is passed as --model only. --- src/backend-router.js | 23 ++++++++++++ src/data/devin-catalog-snapshot.json | 15 +++++++- src/devin-acp.js | 8 +++- src/devin-connect-models.js | 5 +++ src/models.js | 13 +++++++ test/backend-router.test.js | 55 ++++++++++++++++++++++++++++ test/tool-emulation.test.js | 2 + 7 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/backend-router.js b/src/backend-router.js index 716db314..89de261c 100644 --- a/src/backend-router.js +++ b/src/backend-router.js @@ -41,6 +41,21 @@ function isSpecialAgentInfo(modelInfo) { return modelInfo?.backend === 'special_agent'; } +function specialAgentEnabled(env = process.env) { + if (getBackendSwitch('devinOnly', env)) return true; + const backend = String(env?.WINDSURFAPI_SPECIAL_AGENT_BACKEND || '').trim().toLowerCase(); + return backend === 'devin-cli' || env?.DEVIN_CLI_ENABLED === '1'; +} + +// Observed gpt-5.6 SKUs Cascade rejects with "This model is only in Devin Local". +// Anchored to luna/sol/terra (+ effort / -priority). Do not prefix-match gpt-5.60. +// swe-2* is catalogued as backend: 'special_agent' and is handled by that branch. +const GPT56_LOCAL_SKU = /^(?:gpt-5[.-]6|gpt5[.-]6)-(luna|sol|terra)(?:-(none|low|medium|high|xhigh|max)(?:-priority)?)?$/; + +function isDevinLocalOnlyModel(modelInfo) { + return [modelInfo?.name, modelInfo?.modelUid].some((k) => GPT56_LOCAL_SKU.test(String(k || '').trim().toLowerCase())); +} + /** * Resolve the Devin CLI sub-mode (acp vs print). Defaults to print — the same * conservative default special-agent.js uses. @@ -125,6 +140,14 @@ export function selectBackend({ modelInfo = null, env = process.env } = {}) { }; } + if (specialAgentEnabled(env) && isDevinLocalOnlyModel(modelInfo)) { + return { + backend: devinCliMode(env), + reason: 'devin_local_only', + flow: 'special_agent', + }; + } + const modelEnum = modelInfo?.enumValue || 0; const modelUid = modelInfo?.modelUid || null; if (modelUid || modelEnum) { diff --git a/src/data/devin-catalog-snapshot.json b/src/data/devin-catalog-snapshot.json index fa00cf01..70d0a43b 100644 --- a/src/data/devin-catalog-snapshot.json +++ b/src/data/devin-catalog-snapshot.json @@ -1,7 +1,7 @@ { "_comment": "DEVIN_CONNECT catalog whitelist. Refreshed from live GetCascadeModelConfigs 2026-07-08. Live dash-form + enum selectors; MODEL_PRIVATE_* excluded EXCEPT those referenced by SELECTOR_MAP; swe-1-6-slow free-tier fallback preserved. Extended 2026-08-11 from docs.devin.ai modelCostData (gpt-5-6-luna-*, claude-opus-5-*) — NOT yet frame-verified; selector form follows the 4-8/fable family convention.", "_calibratedAt": "2026-07-08", - "_count": 120, + "_count": 123, "models": [ { "selector": "claude-opus-4-8-medium", @@ -41,6 +41,19 @@ "selector": "swe-1-7-lightning", "provider": "cognition" }, + { + "selector": "swe-2-medium", + "provider": "cognition", + "alias": "swe-2" + }, + { + "selector": "swe-2-high", + "provider": "cognition" + }, + { + "selector": "swe-2-max", + "provider": "cognition" + }, { "selector": "claude-opus-4-7-medium", "provider": "anthropic", diff --git a/src/devin-acp.js b/src/devin-acp.js index 3f1fba7e..d18bb97d 100644 --- a/src/devin-acp.js +++ b/src/devin-acp.js @@ -524,6 +524,12 @@ export async function runDevinAcpProcess(prompt, { modelKey = '', apiKey = '', a const command = process.env.DEVIN_CLI_PATH || 'devin'; const env = { ...process.env }; const args = parseAcpArgs(); + // One channel only: `devin acp --model` (do not also set DEVIN_MODEL — the + // two can disagree). Leave DEVIN_PERMISSION_MODE to the operator; do not + // default it to auto. + if (modelKey && !args.includes('--model')) { + args.push('--model', modelKey); + } const client = makeAcpClient({ command, args, @@ -541,7 +547,7 @@ export async function runDevinAcpProcess(prompt, { modelKey = '', apiKey = '', a fs: { readTextFile: false, writeTextFile: false }, terminal: false, }, - clientInfo: { name: 'WindsurfAPI', version: VERSION }, + clientInfo: { name: process.env.DEVIN_ACP_CLIENT_NAME || 'WindsurfAPI', version: VERSION }, }, 30_000); if (init.error) throw errorFromRpcResponse(init, 'Devin ACP initialize failed'); diff --git a/src/devin-connect-models.js b/src/devin-connect-models.js index e93ee6bb..0307ea48 100644 --- a/src/devin-connect-models.js +++ b/src/devin-connect-models.js @@ -121,6 +121,11 @@ const SELECTOR_MAP = new Map(Object.entries({ 'swe-1.7': 'swe-1-7', 'swe-1-7-lightning': 'swe-1-7-lightning', 'swe-1.7-lightning': 'swe-1-7-lightning', + 'swe-2': 'swe-2-medium', + 'swe-2.0': 'swe-2-medium', + 'swe-2-medium': 'swe-2-medium', + 'swe-2-high': 'swe-2-high', + 'swe-2-max': 'swe-2-max', 'deepseek-v4': 'deepseek-v4', // GPT-5.6 family (live catalog 2026-07-09, docs.devin.ai price table). Luna is diff --git a/src/models.js b/src/models.js index e71903b8..40967d86 100644 --- a/src/models.js +++ b/src/models.js @@ -280,6 +280,13 @@ export const MODELS = { // (dash-form modelUid, no special_agent backend — resolved via catalog, not LS). 'swe-1-7': { name: 'swe-1-7', provider: 'windsurf', enumValue: 0, modelUid: 'swe-1-7', credit: 0.5 }, 'swe-1-7-lightning': { name: 'swe-1-7-lightning', provider: 'windsurf', enumValue: 0, modelUid: 'swe-1-7-lightning', credit: 0.5 }, + // SWE-2 — Cognition 2026-09-10 (docs.devin.ai: swe-2-medium/high/max). + // DEVIN_CONNECT=1 uses the snapshot selectors. Cascade has no UID, so the + // LS path marks them special_agent (same as swe-1.6) for an opted-in CLI. + 'swe-2': { name: 'swe-2', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-medium', credit: 6, backend: 'special_agent' }, + 'swe-2-medium': { name: 'swe-2-medium', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-medium', credit: 6, backend: 'special_agent' }, + 'swe-2-high': { name: 'swe-2-high', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-high', credit: 9, backend: 'special_agent' }, + 'swe-2-max': { name: 'swe-2-max', provider: 'windsurf', enumValue: 0, modelUid: 'swe-2-max', credit: 12, backend: 'special_agent' }, // ── Adaptive (Windsurf 2026-04-06 changelog) ──────────── // Adaptive Model Router + Arena models live in the cloud catalog but their @@ -385,6 +392,12 @@ _lookup.set('gpt-5-3-codex-xhigh-priority', 'gpt-5.3-codex-xhigh-fast'); // Cloud-format aliases for existing dotted names _lookup.set('swe-1-6', 'swe-1.6'); _lookup.set('swe-1-6-fast', 'swe-1.6-fast'); +_lookup.set('swe-2.0', 'swe-2'); +_lookup.set('swe-2-0', 'swe-2'); +_lookup.set('swe2', 'swe-2'); +_lookup.set('swe-2.0-medium', 'swe-2-medium'); +_lookup.set('swe-2.0-high', 'swe-2-high'); +_lookup.set('swe-2.0-max', 'swe-2-max'); _lookup.set('minimax-m2-5', 'minimax-m2.5'); _lookup.set('kimi-k2-5', 'kimi-k2.5'); _lookup.set('kimi-k2.6', 'kimi-k2-6'); diff --git a/test/backend-router.test.js b/test/backend-router.test.js index 58b3be3b..27ec7022 100644 --- a/test/backend-router.test.js +++ b/test/backend-router.test.js @@ -33,6 +33,61 @@ describe('backend-router selectBackend — behaviour parity with legacy', () => assert.ok(usesCascadeFlow(sel)); }); + it('gpt-5.6 stays on cascade unless Devin CLI special-agent is enabled', () => { + const sel = selectBackend({ + modelInfo: { name: 'gpt-5.6-luna', modelUid: 'gpt-5-6-luna-medium' }, + env: {}, + }); + assert.equal(sel.flow, 'cascade'); + }); + + it('gpt-5.6-luna → ACP when DEVIN_CLI_ENABLED=1 and DEVIN_CLI_MODE=acp', () => { + const sel = selectBackend({ + modelInfo: { name: 'gpt-5.6-luna', modelUid: 'gpt-5-6-luna-medium' }, + env: { DEVIN_CLI_ENABLED: '1', DEVIN_CLI_MODE: 'acp' }, + }); + assert.equal(sel.backend, BACKEND.DEVIN_ACP); + assert.equal(sel.flow, 'special_agent'); + assert.equal(sel.reason, 'devin_local_only'); + }); + + it('gpt-5.6-luna → print when CLI is on but mode is not acp', () => { + const sel = selectBackend({ + modelInfo: { name: 'gpt-5.6-luna', modelUid: 'gpt-5-6-luna-medium' }, + env: { DEVIN_CLI_ENABLED: '1' }, + }); + assert.equal(sel.backend, BACKEND.DEVIN_PRINT); + assert.equal(sel.reason, 'devin_local_only'); + }); + + it('does not treat gpt-5.60 as a Devin Local gpt-5.6 SKU', () => { + const sel = selectBackend({ + modelInfo: { name: 'gpt-5.60', modelUid: 'gpt-5-60' }, + env: { DEVIN_CLI_ENABLED: '1', DEVIN_CLI_MODE: 'acp' }, + }); + assert.equal(sel.flow, 'cascade'); + assert.equal(sel.reason, 'modelUid'); + }); + + it('swe-2 catalog entries use backend=special_agent (not the gpt-5.6 local-only branch)', () => { + const sel = selectBackend({ + modelInfo: { name: 'swe-2-medium', modelUid: 'swe-2-medium', backend: 'special_agent' }, + env: { DEVIN_CLI_ENABLED: '1', DEVIN_CLI_MODE: 'acp' }, + }); + assert.equal(sel.backend, BACKEND.DEVIN_ACP); + assert.equal(sel.flow, 'special_agent'); + assert.equal(sel.reason, 'modelInfo.backend=special_agent'); + }); + + it('DEVIN_CONNECT=1 still wins over swe-2 special_agent', () => { + const sel = selectBackend({ + modelInfo: { name: 'swe-2-medium', modelUid: 'swe-2-medium', backend: 'special_agent' }, + env: { DEVIN_CONNECT: '1', DEVIN_CLI_ENABLED: '1', DEVIN_CLI_MODE: 'acp' }, + }); + assert.equal(sel.backend, BACKEND.DEVIN_CONNECT); + assert.equal(sel.reason, 'devin_connect'); + }); + it('enumValue > 0 (no uid) → cascade', () => { const sel = selectBackend({ modelInfo: { enumValue: 166 }, env: {} }); assert.equal(sel.backend, BACKEND.CASCADE); diff --git a/test/tool-emulation.test.js b/test/tool-emulation.test.js index 4a06d1e1..e73ac367 100644 --- a/test/tool-emulation.test.js +++ b/test/tool-emulation.test.js @@ -242,6 +242,8 @@ describe('ToolCallStreamParser', () => { assert.equal(pickToolDialect('swe-1-6-slow'), 'openai_json_xml'); assert.equal(pickToolDialect('swe-1-8'), 'openai_json_xml'); assert.equal(pickToolDialect('swe-2-0'), 'openai_json_xml'); + assert.equal(pickToolDialect('swe-2'), 'openai_json_xml'); + assert.equal(pickToolDialect('swe-2-medium'), 'openai_json_xml'); assert.equal(pickToolDialect('swe-1-7-future-slow'), 'openai_json_xml'); }); From cd923e1035871f80608a10608df988d6cce98b3a Mon Sep 17 00:00:00 2001 From: Kevin Lo Date: Mon, 14 Sep 2026 11:59:11 +0900 Subject: [PATCH 2/2] fix(connect): align swe-2 SELECTOR_MAP aliases with models.js _lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1: map swe-2-0 / swe2 / swe-2.0-{medium,high,max} so DEVIN_CONNECT accepts the same names resolveModel already does. N2: document opt-in DEVIN_ACP_CLIENT_NAME in .env.example (default stays WindsurfAPI). N3: gpt-5.60 with CLI on is not a Devin Local SKU — no uid stays legacy; a uid still falls through to cascade, never devin_local_only. --- .env.example | 3 +++ src/devin-connect-models.js | 5 +++++ test/backend-router.test.js | 15 +++++++++++---- test/devin-connect-models.test.js | 20 ++++++++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index c70fe981..525ebe19 100644 --- a/.env.example +++ b/.env.example @@ -134,6 +134,9 @@ LS_PORT=42100 # be used before enabling these in production. # DEVIN_CLI_ALLOW_CLIENT_TOOLS=0 # DEVIN_CLI_ALLOW_MEDIA=0 +# ACP initialize clientInfo.name. Default is WindsurfAPI. Set windsurf only when +# the CLI rejects a custom client name (team-settings 403 on enterprise). +# DEVIN_ACP_CLIENT_NAME=windsurf # After configuring Devin CLI/ACP, validate the route with: # API_KEY=... BASE_URL=http://127.0.0.1:3003 npm run smoke:special-agent diff --git a/src/devin-connect-models.js b/src/devin-connect-models.js index 0307ea48..b9680d8e 100644 --- a/src/devin-connect-models.js +++ b/src/devin-connect-models.js @@ -123,9 +123,14 @@ const SELECTOR_MAP = new Map(Object.entries({ 'swe-1.7-lightning': 'swe-1-7-lightning', 'swe-2': 'swe-2-medium', 'swe-2.0': 'swe-2-medium', + 'swe-2-0': 'swe-2-medium', + 'swe2': 'swe-2-medium', 'swe-2-medium': 'swe-2-medium', 'swe-2-high': 'swe-2-high', 'swe-2-max': 'swe-2-max', + 'swe-2.0-medium': 'swe-2-medium', + 'swe-2.0-high': 'swe-2-high', + 'swe-2.0-max': 'swe-2-max', 'deepseek-v4': 'deepseek-v4', // GPT-5.6 family (live catalog 2026-07-09, docs.devin.ai price table). Luna is diff --git a/test/backend-router.test.js b/test/backend-router.test.js index 27ec7022..5e29b1f8 100644 --- a/test/backend-router.test.js +++ b/test/backend-router.test.js @@ -61,12 +61,19 @@ describe('backend-router selectBackend — behaviour parity with legacy', () => }); it('does not treat gpt-5.60 as a Devin Local gpt-5.6 SKU', () => { - const sel = selectBackend({ + const env = { DEVIN_CLI_ENABLED: '1', DEVIN_CLI_MODE: 'acp' }; + // No uid: hijack would be special_agent / devin_local_only. + const noUid = selectBackend({ modelInfo: { name: 'gpt-5.60' }, env }); + assert.equal(noUid.flow, 'legacy'); + assert.notEqual(noUid.reason, 'devin_local_only'); + // With uid the fall-through is cascade, still not the local-only branch. + const withUid = selectBackend({ modelInfo: { name: 'gpt-5.60', modelUid: 'gpt-5-60' }, - env: { DEVIN_CLI_ENABLED: '1', DEVIN_CLI_MODE: 'acp' }, + env, }); - assert.equal(sel.flow, 'cascade'); - assert.equal(sel.reason, 'modelUid'); + assert.equal(withUid.flow, 'cascade'); + assert.equal(withUid.reason, 'modelUid'); + assert.notEqual(withUid.reason, 'devin_local_only'); }); it('swe-2 catalog entries use backend=special_agent (not the gpt-5.6 local-only branch)', () => { diff --git a/test/devin-connect-models.test.js b/test/devin-connect-models.test.js index 71049585..c523d973 100644 --- a/test/devin-connect-models.test.js +++ b/test/devin-connect-models.test.js @@ -23,6 +23,26 @@ describe('resolveConnectSelector', () => { assert.deepEqual(resolveConnectSelector('swe-1.6-slow'), { selector: 'swe-1-6-slow', mapped: true }); }); + it('maps swe-2 _lookup aliases onto catalog selectors', () => { + // Same names models.js _lookup accepts. Without these SELECTOR_MAP rows, + // DEVIN_CONNECT=1 + STRICT_MODEL=1 400s them even though resolveModel succeeds. + const cases = [ + ['swe-2', 'swe-2-medium'], + ['swe-2.0', 'swe-2-medium'], + ['swe-2-0', 'swe-2-medium'], + ['swe2', 'swe-2-medium'], + ['swe-2-medium', 'swe-2-medium'], + ['swe-2-high', 'swe-2-high'], + ['swe-2-max', 'swe-2-max'], + ['swe-2.0-medium', 'swe-2-medium'], + ['swe-2.0-high', 'swe-2-high'], + ['swe-2.0-max', 'swe-2-max'], + ]; + for (const [name, selector] of cases) { + assert.deepEqual(resolveConnectSelector(name), { selector, mapped: true }, name); + } + }); + it('maps claude friendly names to their captured upstream selectors', () => { assert.equal(resolveConnectSelector('claude-opus-4.8').selector, 'claude-opus-4-8-medium'); // Dashed bare form is a real catalog selector (base model) → resolves to itself;