Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions src/backend-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion src/data/devin-catalog-snapshot.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/devin-acp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');

Expand Down
10 changes: 10 additions & 0 deletions src/devin-connect-models.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ 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-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
Expand Down
13 changes: 13 additions & 0 deletions src/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
62 changes: 62 additions & 0 deletions test/backend-router.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,68 @@ 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 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,
});
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)', () => {
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);
Expand Down
20 changes: 20 additions & 0 deletions test/devin-connect-models.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions test/tool-emulation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down