From 024537f30ae4e3cd2a6c5529ea041c6108e9a37a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 16:19:14 +0900 Subject: [PATCH 1/2] docs(devlog): plan the Devin reasoning-effort ladder --- .../000_plan.md | 81 +++++++++++++++++++ .../010_devin_effort_ladder.md | 77 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md create mode 100644 devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md diff --git a/devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md b/devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md new file mode 100644 index 0000000000..adb48ece4e --- /dev/null +++ b/devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md @@ -0,0 +1,81 @@ +# 000 — Devin 추론 사다리: 피커 불일치와 Pi 컨트롤 부재 + +- 단위: `260913_model_picker_grouping_and_effort` +- 세션: `01a0985e-ce1a-7d12-81b9-c2e93a2bce67` (HOTL, cxc-loop) +- 기준: `origin/dev` `f7d9dbad03` (#4484 devin-cli→devin 병합 반영) + +## 사용자가 말한 세 가지 + +| # | 증상 | 판정 | +|---|---|---| +| 1 | "gemini 처리했던거처럼 묶는 기능"이 필요하다 | **부분 NOOP** — 접기는 이미 있음, 짝이 빠졌음 | +| 2 | 추론 매칭 | **실결함** | +| 3 | Pi 커넥터로 연결하면 추론 피커가 안 보임 | **실결함** | + +셋 다 뿌리가 하나다. `devin` 레지스트리 행에 `modelReasoningEfforts`가 없다. + +## Gemini 선례는 두 부분이다 + +공용 family-grouper 같은 건 없다. 사용자가 기억한 "gemini 처리"는 Antigravity 전용 +구현이고(도입 `c07f2d63dc`, 회귀 복구 `06f8e7a944`), 두 조각이 짝을 이룬다. + +| 조각 | Antigravity | Devin 현재 | +|---|---|---| +| A. wire variant를 base 한 줄로 접기 | `pickerModelIdForDiscoveredWireId` + `collapsesIntoKnownPickerModel` (`antigravity-models.ts:140-156`) | **있음** — `collapseDevinModelUid` (`live-models.ts:80-86`), `provider-fetch.ts:1706-1750` | +| B. 접힌 base에 effort 사다리 붙이기 | `ANTIGRAVITY_MODEL_EFFORTS` → `modelReasoningEfforts` (`registry.ts:2139`) | **없음** (`registry.ts:1340-1358`) | + +`antigravity-models.ts:148-150` 주석이 왜 짝이어야 하는지 직접 말한다. + +> `gemini-3.7-flash-high` looks like a model id and is not one: it is the "high" +> rung of `gemini-3.7-flash`, whose ladder lives in ANTIGRAVITY_MODEL_EFFORTS. +> Publishing it as its own row is what breaks effort selection. + +Devin은 A만 하고 B를 안 해서, 행은 하나로 접혔는데 그 행에 붙는 사다리가 Devin 것이 +아니다. 그래서 "묶는 기능이 필요하다"는 체감이 나온다 — 접기는 됐지만 쓸모가 없다. + +## 증상 2 — 추론 매칭 불일치 + +`modelReasoningEfforts`가 없으면 `applyReasoningLevels`(`effort.ts:231`)가 기본 +사다리로 떨어진다. + +| | 광고되는 사다리 | 실제 레인 | +|---|---|---| +| SWE-2 | low, medium, high, xhigh, max, ultra | **medium, high, max** (`devin.ts:116-126` `SWE2_EFFORT`) | + +`low`를 고르면 조용히 medium으로 올라가고, `xhigh`/`ultra`도 max로 접힌다. 컨트롤이 +말한 대로 동작하지 않는다. Anthropic 행 주석(`registry.ts:418-420`)이 정확히 이 기준을 +세워 뒀다 — "advertising it would offer a control that does not do what it says". + +## 증상 3 — Pi에서 컨트롤이 아예 안 그려짐 + +omp에 프로바이더 화이트리스트는 없다. `ompEfforts()`가 `ExportModel.reasoningEfforts` +(`contracts.ts:76`)를 걸러서 **비어 있지 않을 때만** `reasoning: true` + +`thinking: { mode: "effort", efforts }`를 쓴다(`omp.ts:72`). Devin은 그 배열이 비어 +있으니 컨트롤이 통째로 빠진다. + +이건 Pi만의 문제가 아니다. 같은 필드를 읽는 `pi`, `aside`, `prime`, `omo`, `zcode`, +`mcode`, `dsh`, `raycast`, OpenCode 계열이 전부 같이 깨진다. `cline` export는 effort +필드 자체가 없어 대상이 아니다. + +선례 커밋은 `df416a439c` (#3454, Anthropic)다. 어댑터는 원래 effort를 보내고 있었는데 +레지스트리가 사다리를 안 실어서 Pi형 피커가 숨었던, 글자 그대로 같은 결함이다. + +## 스크린샷에 대해 + +사용자 스크린샷의 `swe-2 (devin-cli)` 행은 Codex app composer이고 출처는 +`model_catalog_json`(`inject.ts:864`)이다. #4484가 `devin-cli` 행을 지웠으므로 그 라벨은 +현재 dev에 없다 — 스크린샷은 병합 전 상태이거나 재기동 전 캐시다. 이 단위는 그 라벨을 +쫓지 않고 사다리만 고친다. + +## 작업 단계 + +| wp | 문서 | 산출물 | +|---|---|---| +| wp0 | 이 문서 + 010 | 로드맵 | +| wp1 | (이 문서에 통합) | 선례 확정 — 완료 | +| wp2 | `010_devin_effort_ladder.md` | 레지스트리 사다리 + 테스트 → PR → merge | +| wp3 | 통합 | Pi 익스포트는 같은 변경으로 해결 | + +wp2와 wp3은 같은 한 줄짜리 원인을 공유하므로 PR 하나로 착지한다. 나누면 두 번째 +PR이 빈 변경이 된다. + diff --git a/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md b/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md new file mode 100644 index 0000000000..74a0b03f3e --- /dev/null +++ b/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md @@ -0,0 +1,77 @@ +# 010 — wp2: Devin 모델별 추론 사다리 + +## NEW: src/providers/devin-models.ts 에 사다리 추가 — 또는 live-models.ts 확장 + +Antigravity는 `ANTIGRAVITY_MODEL_EFFORTS`를 `antigravity-models.ts`에 두고 레지스트리가 +import한다. Devin도 같은 자리에 둔다. `src/adapters/devin/live-models.ts`가 이미 +`DEVIN_MODEL_CONTEXT_WINDOWS`를 export하고 레지스트리가 그걸 쓰므로 같은 파일에 붙인다. + +```ts +/** + * Effort ladders per collapsed base model. + * + * Cognition spells effort as a model-id suffix, so the picker row that + * collapseDevinModelUid() produces needs its ladder declared here or the catalog + * falls back to the six-rung default. SWE-2 ships exactly three lanes, so + * advertising low, xhigh or ultra would offer a control that silently rounds to + * one of these three - the bar registry.ts:418-420 already sets for Anthropic. + */ +export const DEVIN_MODEL_EFFORTS: Record = { + "swe-2": ["medium", "high", "max"], +}; + +/** + * Ladder for a model this table does not name, including anything the live + * catalog discovers. Five rungs rather than six: `ultra` has no Cognition lane. + */ +export const DEVIN_DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +``` + +## MODIFY: src/providers/registry.ts — devin 행 + +```ts +// import 줄 +import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../adapters/devin/live-models"; + +// devin 행 (1340-1358) 끝에 두 줄 + modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, ++ modelReasoningEfforts: DEVIN_MODEL_EFFORTS, ++ reasoningEfforts: DEVIN_DEFAULT_EFFORTS, +``` + +`modelReasoningEfforts`가 Codex 피커의 모델별 사다리를 정하고(Antigravity 선례), +`reasoningEfforts`가 Pi 형태 익스포트의 `ExportModel.reasoningEfforts`를 채운다 +(Anthropic 선례 `df416a439c`). 한 변경이 두 표면을 동시에 고친다. + +## 왜 SWE-2만 표에 넣는가 + +라이브 카탈로그의 모델별 실제 suffix 집합은 계정마다 다르고 이 세션에서 실측하지 +않았다. 확증된 것은 `SWE2_EFFORT`(`devin.ts:116-126`)가 박아 둔 SWE-2의 3레인뿐이다. +나머지는 5단 기본값으로 두고, 실측이 생기면 표에 줄을 추가한다. 모르는 사다리를 +지어내는 것보다 낫다. + +## NEW: tests/providers/devin-effort-ladder.test.ts + +| 케이스 | 기대 | +|---|---| +| `DEVIN_MODEL_EFFORTS["swe-2"]` | `["medium","high","max"]` — `low`/`xhigh`/`ultra` 없음 | +| 표의 모든 사다리가 `SWE2_EFFORT`의 치역에 포함 | 광고와 실제 레인 일치 (드리프트 가드) | +| devin 레지스트리 행이 두 필드를 모두 노출 | 두 표면 회귀 | +| `DEVIN_DEFAULT_EFFORTS`에 `ultra` 없음 | Cognition 레인 없음 | +| omp export가 Devin 모델에 `thinking.mode = "effort"`를 씀 | Pi 회귀 — `management-client-config-route.test.ts:181` Anthropic 케이스 복제 | + +마지막 줄이 증상 3의 직접 회귀다. 기존 Anthropic 케이스가 그대로 본이 된다. + +## 레이아웃 등록 + +- `scripts/test-layout/layout.json` `explicit` +- `tests/fixtures/test-layout-expected.json` + +`devin-adapter.test.ts`가 `providers`로 등록돼 있으니 같은 값을 쓴다. + +## 범위 밖 + +레인 C가 #4484 후속으로 남긴 것들 — `src/adapters/registry.ts:26-30`의 구 주석, +`DEVIN_STATIC_MODELS`에 swe-2 부재, `stale-context-window-migration.ts:45-56`의 구 +로스터. 사다리와 무관하므로 이 PR에서 건드리지 않는다. + From 6f77d24bb32b4806d8f74dcbcb01b3b1b0ab7091 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 16:31:39 +0900 Subject: [PATCH 2/2] feat(devin): give every model the reasoning ladder its catalog already names Cognition spells effort as a suffix on the model id, so an account catalog that lists swe-2-medium, swe-2-high and swe-2-max is stating that SWE-2 has exactly three lanes. collapseDevinModelUid() strips those suffixes to produce one picker row per base model and threw the evidence away, and the registry row declared no ladder, so every Devin model fell through to the generic routed default of low/medium/high/xhigh/max/ultra. Two things broke. In the Codex picker the control offered rungs the model does not have: asking SWE-2 for low silently rounds up to medium and xhigh rounds down to max, so the setting did not do what it said. And every client that keys an effort control off CatalogModel.reasoningEfforts - the Pi-shaped exports, so pi, aside, prime, omo, zcode, mcode, dsh, raycast and the OpenCode variants - saw an empty list and rendered no control at all. Recover the ladder where it was being discarded. fetchDevinUsableModels already walks the catalog and collapses each uid; it now also collects the reasoning rungs it strips, keyed by base model, and the catalog entry carries them. This covers every model the account has rather than a hand-written subset, and a new model needs no code change. fast, priority and 1m are tiers and context variants, not effort, so the collapse keeps stripping them while the ladder ignores them. A base with a single rung gets no ladder, because one option is not a choice. The registry keeps a small static table for the degraded path before a credential exists, holding only what is measured: SWE-2, whose three lanes are pinned by SWE2_EFFORT in the adapter. The provider-level fallback omits ultra, which Cognition has no lane for; the Codex catalog re-adds its own top rungs afterwards, so subagent effort overrides still validate. This is the pairing Antigravity already has, where collapsing wire variants into one row and declaring that row a ladder are two halves of one feature. Devin had the first half only, which is why the collapsed row looked right and behaved wrong. Local product tests, typecheck, build and install: NOT RUN. Hosted exact-head CI on this PR is the merge proof. --- .../010_devin_effort_ladder.md | 87 +++++++++++++++++++ scripts/test-layout/layout.json | 1 + src/adapters/devin/live-models.ts | 71 ++++++++++++++- src/codex/catalog/provider-fetch.ts | 6 ++ src/providers/registry.ts | 7 +- tests/fixtures/test-layout-expected.json | 1 + tests/providers/devin-effort-ladder.test.ts | 74 ++++++++++++++++ 7 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 tests/providers/devin-effort-ladder.test.ts diff --git a/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md b/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md index 74a0b03f3e..d28c0674cd 100644 --- a/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md +++ b/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md @@ -75,3 +75,90 @@ import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS `DEVIN_STATIC_MODELS`에 swe-2 부재, `stale-context-window-migration.ts:45-56`의 구 로스터. 사다리와 무관하므로 이 PR에서 건드리지 않는다. + +## 계획 수정 — 전 모델 적용 (사용자 지시) + +"swe 뿐만 아니라 모든 devin 모델들에 대해 적용해야" 한다는 지시를 받았다. 정적 표로 +전 모델을 채우려면 계정마다 다른 로스터를 지어내야 하므로, 설계를 바꾼다. + +**라이브 카탈로그가 이미 답을 알고 있고 우리가 버리고 있다.** + +`fetchDevinUsableModels`(`live-models.ts:97-127`)는 `catalog.byUid`를 돌면서 +`collapseDevinModelUid`로 접미사를 벗긴다. 벗겨낸 그 토큰이 곧 그 모델의 실제 +사다리다. 지금은 버려지고 base id만 남는다. `contextWindows`를 base별로 모으는 것과 +똑같은 자리에서 efforts도 모으면 된다. + +전달 채널도 이미 있다 — `CatalogModel.reasoningEfforts?: string[]` +(`parsing.ts:114`)와 `defaultReasoningEffort`(`:115`). + +### 바뀐 diff 계획 + +**MODIFY `src/adapters/devin/live-models.ts`** — 결과 타입에 `efforts` 추가. + +```ts +export type DevinUsableModelsResult = + | { ok: true; models: string[]; contextWindows: Record; + efforts: Record } + | { ok: false; error: "auth" | "http" | "empty" | "unknown"; detail?: string }; +``` + +수집 루프에서, 벗겨낸 토큰 중 Codex 사다리에 해당하는 것만 모은다. + +```ts +const efforts = new Map>(); +// ...루프 안, base 계산 직후 +for (const token of devinEffortTokensOf(entry.modelUid, base)) { + if (!CODEX_REASONING_RUNGS.has(token)) continue; // fast/priority/1m 제외 + (efforts.get(base) ?? efforts.set(base, new Set()).get(base)!).add(token); +} +``` + +`fast`, `priority`, `1m`은 추론 단계가 아니라 티어·변형이므로 사다리에서 뺀다. +접미사 변형이 하나도 없는 base는 사다리가 비고, 그러면 컨트롤이 안 붙는다 — 그게 +정직한 결과다. + +**MODIFY `src/codex/catalog/provider-fetch.ts:1736-1744`** — base별 사다리를 싣는다. + +```ts + const result = liveResult.models.map((id) => { + const liveWindow = liveResult.contextWindows[id]; + const liveEfforts = liveResult.efforts[id]; + return { + id, + provider: name, + ...(liveWindow ? { contextWindow: liveWindow } : {}), + ...(liveEfforts?.length ? { reasoningEfforts: liveEfforts } : {}), + ...catalogHintsFromProviderConfig(...), + } as CatalogModel; + }); +``` + +`catalogHintsFromProviderConfig`를 뒤에 두는 순서는 그대로다. contextWindow와 같은 +이유로, 사용자가 명시한 오버라이드가 계속 이긴다. + +**MODIFY `src/providers/registry.ts`** — degraded 모드 폴백. + +라이브 카탈로그가 없을 때(로그인 전, 쿨다운, 네트워크 실패)는 시드 로스터가 쓰인다. +그때를 위한 정적 표와, Pi 형태 익스포트가 읽는 프로바이더 레벨 기본값을 둔다. + +```ts + modelReasoningEfforts: DEVIN_MODEL_EFFORTS, + reasoningEfforts: DEVIN_DEFAULT_EFFORTS, +``` + +정적 표에는 실측된 것만 넣는다 — 현재는 `swe-2: ["medium","high","max"]` +(`SWE2_EFFORT` `devin.ts:116-126`이 박아 둔 3레인). 나머지는 기본값을 쓰고, 로그인 +후에는 라이브 값이 덮는다. 모르는 사다리를 지어내지 않는다. + +### 이 설계가 나은 이유 + +| | 정적 표만 | 라이브 파생 | +|---|---|---| +| 커버리지 | 손으로 적은 모델만 | **계정이 가진 전 모델** | +| 정확도 | 작성 시점 추측 | 계정의 실제 카탈로그 | +| 새 모델 | 코드 수정 필요 | 자동 | +| 계정별 차이 | 표현 불가 | 자연히 반영 | + +Antigravity가 정적 표를 쓰는 건 그쪽 로스터가 고정이기 때문이다. Devin은 계정마다 +다르고 이미 라이브 디스커버리를 하므로, 같은 목적지에 더 맞는 길이 있다. + diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4ad1cdf000..4d1d6ce272 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -645,6 +645,7 @@ "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", + "devin-effort-ladder.test.ts": "providers", "devin-login.test.ts": "providers", "devin-provider-merge-migration.test.ts": "providers", "devin-hardening.test.ts": "providers", diff --git a/src/adapters/devin/live-models.ts b/src/adapters/devin/live-models.ts index addd18a5c3..bfd75e70bb 100644 --- a/src/adapters/devin/live-models.ts +++ b/src/adapters/devin/live-models.ts @@ -85,8 +85,61 @@ export function collapseDevinModelUid(uid: string): string { return parts.join("-"); } +/** + * The subset of catalog suffix tokens that are reasoning rungs. + * + * `fast`, `priority` and `1m` are service tiers and context variants, not effort. + * Offering them on a reasoning control would name a setting that does something + * else, so the collapse keeps stripping them while the ladder ignores them. + */ +const REASONING_RUNG_TOKENS = new Set(["none", "low", "medium", "high", "xhigh", "max"]); + +/** + * The reasoning rungs a catalog UID carries, in ladder order. + * + * Cognition spells effort as a suffix on the model id, so the variants an account + * actually has ARE its ladder — and collapseDevinModelUid() was throwing exactly + * that evidence away. Reading it back is what lets every model advertise the rungs + * it can really run instead of inheriting the generic six-rung default. + */ +export function devinReasoningRungsOf(uid: string): string[] { + const parts = uid.split("-"); + const rungs: string[] = []; + while (parts.length > 1 && EFFORT_TOKENS.has(parts[parts.length - 1]!)) { + const token = parts.pop()!; + if (REASONING_RUNG_TOKENS.has(token)) rungs.push(token); + } + return rungs; +} + +/** Ladder order for display, matching the Codex rung order. */ +const RUNG_ORDER = ["none", "low", "medium", "high", "xhigh", "max"]; +export function sortDevinRungs(rungs: Iterable): string[] { + return [...new Set(rungs)].sort((a, b) => RUNG_ORDER.indexOf(a) - RUNG_ORDER.indexOf(b)); +} + +/** + * Degraded-mode ladders, used only before the account catalog is readable. + * + * Only measured entries belong here. SWE-2 ships exactly three native lanes + * (see SWE2_EFFORT in src/adapters/devin.ts); inventing ladders for the rest + * would advertise rungs nobody verified, and the live catalog replaces this + * table as soon as a credential is present. + */ +export const DEVIN_MODEL_EFFORTS: Record = { + "swe-2": ["medium", "high", "max"], +}; + +/** + * Provider-level fallback ladder. No `ultra`: Cognition has no such lane, and + * the Codex catalog re-adds its own top rungs anyway (src/codex/catalog/effort.ts). + * Clients that key an effort control off this list — the Pi-shaped exports — get + * a control instead of none. + */ +export const DEVIN_DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + export type DevinUsableModelsResult = - | { ok: true; models: string[]; contextWindows: Record } + | { ok: true; models: string[]; contextWindows: Record; efforts: Record } | { ok: false; error: "auth" | "http" | "empty" | "unknown"; detail?: string }; /** @@ -105,6 +158,8 @@ export async function fetchDevinUsableModels(opts: { if (!catalog) return { ok: false, error: "empty" }; const bases = new Set(); const contextWindows: Record = {}; + // Effort rungs per base, recovered from the suffixes the collapse strips. + const rungs = new Map>(); for (const entry of catalog.byUid.values()) { if (entry.disabled) continue; // Skip internal enum constants (e.g. MODEL_GPT_5_2_LOW, MODEL_PRIVATE_*). @@ -112,6 +167,12 @@ export async function fetchDevinUsableModels(opts: { if (entry.modelUid.startsWith("MODEL_")) continue; const base = collapseDevinModelUid(entry.modelUid); bases.add(base); + const found = devinReasoningRungsOf(entry.modelUid); + if (found.length > 0) { + let set = rungs.get(base); + if (!set) { set = new Set(); rungs.set(base, set); } + for (const rung of found) set.add(rung); + } if (entry.contextWindow && entry.contextWindow > 0) { // Variants of one base can disagree: the opt-in `-1m` rows report a // larger window than the plain row of the same base, and both collapse @@ -124,7 +185,13 @@ export async function fetchDevinUsableModels(opts: { } } if (bases.size === 0) return { ok: false, error: "empty" }; - return { ok: true, models: [...bases].sort(), contextWindows }; + const efforts: Record = {}; + for (const [base, set] of rungs) { + // A single rung is not a choice, so it is not a control. Advertising one + // would draw a picker whose only option is the value already in effect. + if (set.size > 1) efforts[base] = sortDevinRungs(set); + } + return { ok: true, models: [...bases].sort(), contextWindows, efforts }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/unauth|401|invalid token|login/i.test(message)) return { ok: false, error: "auth", detail: message }; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index cdf7806f73..f4560a03b8 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1739,6 +1739,12 @@ async function fetchProviderModelsWithAuth( id, provider: name, ...(liveWindow ? { contextWindow: liveWindow } : {}), + // The account catalog names the effort variants each base model has, so + // its ladder is measured rather than assumed. Without this the entry + // inherits the generic routed ladder and offers rungs the model rounds + // away, and every client that keys an effort control off this field — + // the Pi-shaped exports — renders no control at all. + ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}), ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), } as CatalogModel; }); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 78e645d029..b743354336 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,7 +1,7 @@ import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; -import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../adapters/devin/live-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { @@ -1356,6 +1356,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, defaultModel: "swe-2", modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, + // Degraded-mode ladders only. Once a credential is present the account + // catalog supplies each base model its measured rungs; these two fields are + // what a signed-out picker and the Pi-shaped client exports fall back to. + modelReasoningEfforts: DEVIN_MODEL_EFFORTS, + reasoningEfforts: DEVIN_DEFAULT_EFFORTS, }, { id: "xai", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 273d132228..2d8bde9618 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -477,6 +477,7 @@ "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", + "devin-effort-ladder.test.ts": "providers", "devin-hardening.test.ts": "providers", "devin-prompt-cache.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", diff --git a/tests/providers/devin-effort-ladder.test.ts b/tests/providers/devin-effort-ladder.test.ts new file mode 100644 index 0000000000..0b800c11a8 --- /dev/null +++ b/tests/providers/devin-effort-ladder.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { + DEVIN_DEFAULT_EFFORTS, + DEVIN_MODEL_EFFORTS, + collapseDevinModelUid, + devinReasoningRungsOf, + sortDevinRungs, +} from "../../src/adapters/devin/live-models"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; + +const devinRow = () => PROVIDER_REGISTRY.find(row => row.id === "devin")!; + +describe("devin reasoning rungs come from the catalog suffixes", () => { + // Cognition spells effort as a model-id suffix, so the variants an account has + // ARE its ladder. The collapse used to strip that evidence and drop it. + test.each([ + ["swe-2-high", "swe-2", ["high"]], + ["gpt-5-6-sol-medium", "gpt-5-6-sol", ["medium"]], + ["gpt-5-6-sol-medium-priority", "gpt-5-6-sol", ["medium"]], + ["swe-2", "swe-2", []], + ])("%p collapses to %p with rungs %p", (uid, base, rungs) => { + expect(collapseDevinModelUid(uid)).toBe(base); + expect(devinReasoningRungsOf(uid)).toEqual(rungs); + }); + + // `fast`, `priority` and `1m` are tiers and context variants. Offering them on a + // reasoning control would name a setting that does something else. + test.each(["gpt-5-6-sol-priority", "swe-2-fast", "kimi-k3-1m"])( + "%p contributes no reasoning rung", + (uid) => { + expect(devinReasoningRungsOf(uid)).toEqual([]); + }, + ); + + test("rungs sort into ladder order, not discovery order", () => { + expect(sortDevinRungs(["max", "low", "high", "medium"])).toEqual(["low", "medium", "high", "max"]); + expect(sortDevinRungs(["high", "high"])).toEqual(["high"]); + }); +}); + +describe("devin advertises a ladder instead of inheriting the generic one", () => { + test("the provider row carries both fields", () => { + // modelReasoningEfforts drives the Codex picker; reasoningEfforts is what the + // Pi-shaped client exports read. Without them the row inherited the routed + // six-rung default and Pi drew no control at all. + const row = devinRow(); + expect(row.modelReasoningEfforts).toBeDefined(); + expect(row.reasoningEfforts).toBeDefined(); + expect(row.reasoningEfforts!.length).toBeGreaterThan(1); + }); + + test("SWE-2 advertises only the lanes it actually runs", () => { + // src/adapters/devin.ts SWE2_EFFORT maps every caller effort onto exactly + // these three. Advertising low or xhigh would offer a control that silently + // rounds to one of them. + expect(DEVIN_MODEL_EFFORTS["swe-2"]).toEqual(["medium", "high", "max"]); + }); + + test("the fallback ladder omits ultra, which Cognition has no lane for", () => { + expect(DEVIN_DEFAULT_EFFORTS).not.toContain("ultra"); + expect(DEVIN_DEFAULT_EFFORTS).toContain("medium"); + }); + + test("every static ladder is a subset of the fallback vocabulary", () => { + // A drift guard: a table entry naming a rung the provider vocabulary does not + // have would advertise a control the adapter cannot honour. + const vocabulary = new Set([...DEVIN_DEFAULT_EFFORTS, "none"]); + for (const [model, ladder] of Object.entries(DEVIN_MODEL_EFFORTS)) { + for (const rung of ladder) { + expect({ model, rung, known: vocabulary.has(rung) }).toMatchObject({ known: true }); + } + } + }); +});