diff --git a/changelog.d/fixes/6142-6142-devin-unified-api.md b/changelog.d/fixes/6142-6142-devin-unified-api.md new file mode 100644 index 0000000000..bda88bd4c8 --- /dev/null +++ b/changelog.d/fixes/6142-6142-devin-unified-api.md @@ -0,0 +1 @@ +- fix(providers): wire the Devin cloud-agent provider into the generic provider-page validator and static model catalog, matching the existing `jules` cloud-agent pattern (#6142) diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 271590670b..88bb6d85d8 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -79,6 +79,12 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str // Google Labs async coding agent — single async session, no model selection. { id: "jules", name: "Jules (Google Labs coding agent)" }, ], + devin: () => [ + // Cognition's Devin cloud-agent sessions don't expose per-request model + // selection like devin-cli's ACP models do — single non-selectable placeholder + // so the "Available Models" UI shows something instead of a hard failure (#6142). + { id: "devin", name: "Devin (Cognition cloud agent)" }, + ], "linkup-search": () => [ // Linkup web search — the "model" is the search depth (docs.linkup.so #5571). { id: "standard", name: "Standard (single-iteration agentic search)" }, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index f97d8eeb13..08eccca4d6 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -45,6 +45,7 @@ import { validateCopilotWebProvider, validateT3WebProvider, validateJulesProvider, + validateDevinCloudAgentProvider, validateInnerAiProvider, } from "./validation/webProvidersB"; import { @@ -390,6 +391,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi } }, jules: validateJulesProvider, + // "devin" is the Cognition cloud-agent provider (distinct from the "devin-cli" + // LLM/ACP provider, which is already registered in providerRegistry). Wired here + // for parity with the "jules" cloud-agent entry above — see #6142. + devin: validateDevinCloudAgentProvider, // auggie is a fully local, credential-less CLI passthrough — there is no API // key to check upstream. The only meaningful validation is confirming the // `auggie` binary is installed and runnable on this machine. diff --git a/src/lib/providers/validation/webProvidersB.ts b/src/lib/providers/validation/webProvidersB.ts index e28e135c4c..9a1afb128b 100644 --- a/src/lib/providers/validation/webProvidersB.ts +++ b/src/lib/providers/validation/webProvidersB.ts @@ -1,6 +1,7 @@ // Web-cookie provider key validators (part B): muse-spark-web, adapta-web, claude-web, gemini-web, -// copilot-web, t3-web, jules, inner-ai. Extracted from validation.ts (god-file decomposition) — -// top-level functions with no dispatcher-state captures; behavior is byte-identical to the inline defs. +// copilot-web, t3-web, jules, devin (cloud-agent), inner-ai. Extracted from validation.ts (god-file +// decomposition) — top-level functions with no dispatcher-state captures; behavior is byte-identical +// to the inline defs. import { applyCustomUserAgent } from "./headers"; import { toValidationErrorResult, validationRead, validationWrite } from "./transport"; import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; @@ -453,6 +454,38 @@ export async function validateJulesProvider({ apiKey }: { apiKey: string }) { } } +/** + * Devin cloud-agent (Cognition) — GET /v1/sessions with Bearer auth + * (see docs.devin.ai/api-reference/sessions/list-sessions). Distinct from the + * "devin-cli" LLM provider (ACP), which is already wired via providerRegistry. + */ +export async function validateDevinCloudAgentProvider({ apiKey }: { apiKey: string }) { + try { + const response = await validationWrite("https://api.devin.ai/v1/sessions?limit=1", { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + if (response.ok) { + return { valid: true, error: null }; + } + + const errorText = await response.text().catch(() => ""); + return { + valid: false, + error: errorText.trim() || `Devin API returned ${response.status}`, + }; + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} + export async function validateInnerAiProvider({ apiKey, providerSpecificData = {} }: any) { try { const raw = typeof apiKey === "string" ? apiKey.trim() : ""; diff --git a/tests/unit/devin-cloud-agent-validator-6142.test.ts b/tests/unit/devin-cloud-agent-validator-6142.test.ts new file mode 100644 index 0000000000..39463beadb --- /dev/null +++ b/tests/unit/devin-cloud-agent-validator-6142.test.ts @@ -0,0 +1,71 @@ +// #6142 — devin cloud-agent provider validator + static model catalog wiring. +// Regression guard for the "not supported" fallback devin used to always hit on the +// generic Providers config page (parity with the existing jules cloud-agent wiring). +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { validateProviderApiKey } from "../../src/lib/providers/validation.ts"; +import { validateDevinCloudAgentProvider } from "../../src/lib/providers/validation/webProvidersB.ts"; +import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts"; + +test("#6142: devin cloud-agent validator is wired into the SPECIALTY_VALIDATORS dispatcher", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("{}", { status: 401 })) as unknown as typeof fetch; + try { + const result = await validateProviderApiKey({ + provider: "devin", + apiKey: "cog_bad_key", + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); + assert.notEqual(result.unsupported, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#6142: validateDevinCloudAgentProvider maps 401 to Invalid API key", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("{}", { status: 401 })) as unknown as typeof fetch; + try { + const result = await validateDevinCloudAgentProvider({ apiKey: "bad-key" }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#6142: validateDevinCloudAgentProvider maps 403 to Invalid API key", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response("{}", { status: 403 })) as unknown as typeof fetch; + try { + const result = await validateDevinCloudAgentProvider({ apiKey: "bad-key" }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#6142: validateDevinCloudAgentProvider accepts a 2xx probe as valid", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ sessions: [] }), { status: 200 })) as unknown as typeof fetch; + try { + const result = await validateDevinCloudAgentProvider({ apiKey: "cog_good_key" }); + assert.equal(result.valid, true); + assert.equal(result.error, null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("#6142: devin exposes a static model catalog for the 'Available Models' UI (parity with jules)", () => { + const models = getStaticModelsForProvider("devin"); + assert.ok(Array.isArray(models) && models.length > 0); + assert.equal(models[0].id, "devin"); +}); diff --git a/tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts b/tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts new file mode 100644 index 0000000000..015e7e1300 --- /dev/null +++ b/tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts @@ -0,0 +1,27 @@ +// tests/unit/repro-6142-devin-cloud-agent-unwired.test.ts +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { validateProviderApiKey } from "../../src/lib/providers/validation"; +import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels"; + +test("#6142 (fixed): saving a Devin cloud-agent API key should not be 'unsupported' by the generic provider flow (parity with jules)", async () => { + const result = await validateProviderApiKey({ + provider: "devin", + apiKey: "cog_fake_service_user_token_for_repro", + }); + assert.notEqual( + result.error, + "Provider validation not supported", + "devin should have a specialty validator wired (like jules), not fall through to 'not supported'" + ); + assert.notEqual(result.unsupported, true); +}); + +test("#6142 (fixed): the 'Available Models' UI should have a usable static catalog for devin (parity with jules)", () => { + const devinStaticModels = getStaticModelsForProvider("devin"); + assert.ok( + Array.isArray(devinStaticModels) && devinStaticModels.length > 0, + "devin should expose a static model catalog like jules does" + ); +}); diff --git a/tests/unit/validation-web-providers-split.test.ts b/tests/unit/validation-web-providers-split.test.ts index 8b947604e7..0b321c69ed 100644 --- a/tests/unit/validation-web-providers-split.test.ts +++ b/tests/unit/validation-web-providers-split.test.ts @@ -25,7 +25,7 @@ test("webProvidersA exposes its six validators (deepseek/qwen/grok/chatgpt/perpl } }); -test("webProvidersB exposes its eight validators (muse-spark/adapta/claude/gemini/copilot/t3/jules/inner-ai)", () => { +test("webProvidersB exposes its nine validators (muse-spark/adapta/claude/gemini/copilot/t3/jules/devin/inner-ai)", () => { for (const name of [ "validateMuseSparkWebProvider", "validateAdaptaWebProvider", @@ -34,6 +34,7 @@ test("webProvidersB exposes its eight validators (muse-spark/adapta/claude/gemin "validateCopilotWebProvider", "validateT3WebProvider", "validateJulesProvider", + "validateDevinCloudAgentProvider", "validateInnerAiProvider", ]) { assert.equal(typeof (B as Record)[name], "function", `B missing ${name}`);