diff --git a/.gitignore b/.gitignore index ce10233dcc..00394f7dee 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ src/generated/compatibility-version.json devlog/_chase/_cca/ devlog/_chase/_litellm/ devlog/_chase/DSCodex/ +devlog/_chase/CLIProxyAPIPlus/ devlog/_fin/opencode-cursor/ devlog/_plan/*/_ref_*/ devlog/**/*-security-redaction/ diff --git a/devlog/_plan/260913_devin_landing_and_caching/000_plan.md b/devlog/_plan/260913_devin_landing_and_caching/000_plan.md new file mode 100644 index 0000000000..e0af11ea20 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/000_plan.md @@ -0,0 +1,154 @@ +# 000 — Devin 착지와 캐싱 개선 (계획) + +- 단위 슬러그: `260913_devin_landing_and_caching` +- 세션: `01a0985e-ce1a-7d12-81b9-c2e93a2bce67` (HOTL, cxc-loop) +- 기준 HEAD: `7ca00ffe7c1299e80d650a3243b2bc7cf09109ad` (= `origin/dev`, 확인 시각 2026-09-13) +- 워크트리: `/Users/jun/.codex/worktrees/8513/opencodex` (detached, app-managed) + +## 목적 + +열려 있는 Devin 관련 draft 두 건을 현재 `dev`에 착지시키고, 그 위에서 Devin +트랜스포트의 캐싱을 CLIProxyAPIPlus와 omp/omo보다 낫게 만든다. 작업 중 사용자가 +실제로 맞은 런타임 오류(`stream disconnected before completion: cloud-direct: +time-to-first-byte timeout (60000ms)`)도 같은 단위에서 근본 원인까지 고친다. + +그 오류는 부수적인 잡음이 아니라 이 단위의 핵심이다. Devin에게 직접 코드를 +수정시키려던 시도가 실패한 이유가 바로 이것이고, 아래 wp3에서 보듯 프록시가 +살아 있는 업스트림을 스스로 끊고 있었다. + +## 제약 (사용자 지시 + AGENTS.md) + +| 제약 | 내용 | +|---|---| +| 로컬 스위트 금지 | `bun run test` / `typecheck` / `build` / `install` / `structure:check` / `privacy:scan` 모두 **NOT RUN**. 증거는 carry PR의 exact-final-head hosted CI. | +| 푸시 경로 | `dev`/`main`/`preview` 직접 푸시 금지. 전부 PR 경유. | +| 머지 권한 | `lidge-jun`은 `admin`. MAINTAINERS.md의 maintainer integration 조항으로 `dev` 한정 단독 통합 가능. 결정과 exact-head 검증을 PR에 기록해야 한다. | +| 저작자 보존 | 남의 PR을 carry하면 `Co-authored-by` 트레일러 필수 (AGENTS.md, CREDITS.md). 산문 언급은 무효. | +| 보안 노트 | 미공개 취약점 분석은 `.tmp/`에만. `devlog/`는 공개 디렉터리다. | +| 서브에이전트 | `xai/grok-4.6` 무제한 병렬 파견 허용 (사용자 명시). 리프는 쓰기 범위가 서로 겹치지 않아야 한다. | + +## 조사 산출물 (이 계획의 근거) + +5개 레인을 `xai/grok-4.6`으로 병렬 파견해 얻은 read-only 리포트. 전부 `.tmp/`에 있고 +추적되지 않는다. + +| 레인 | 산출물 | 핵심 결론 | +|---|---|---| +| A | `.tmp/research/laneA-devin-binary.md` | 로컬 Devin CLI `3000.10.21 (611c1cba)` 해부 | +| B | `.tmp/research/laneB-cliproxyapiplus.md` | Plus vs omp/omo vs opencodex 3자 대조 | +| C | `.tmp/research/laneC-our-devin.md` | 자사 devin/devin-cli 캐싱 전수 인벤토리 | +| D | `.tmp/research/laneD-ttfb.md` | TTFB 504 근본 원인 + 라이브 로그 3건 | +| E | `.tmp/research/laneE-carry-prs.md` | #4420/#4384 patch, 트레일러, apply 검증 | + +참조 클론: `devlog/_chase/CLIProxyAPIPlus/` (gitignored, AGENTS.md `_chase` 규약). + +## 3자 대조 요약 + +가장 중요한 발견은 두 구현이 정확히 반대 방향으로 반쪽이라는 것이다. + +| 능력 | CLIProxyAPIPlus | omp/omo | opencodex (오늘) | 판정 | +|---|---|---|---|---| +| 세션/캐스케이드 재사용 | 매 요청 새로 생성 (`devin_executor.go:626-637`) | 해당 없음 | `(host, apiKey)` 재사용 (`chat.ts:72-91`) | **OCX 우위** | +| 프롬프트 캐시 옵션 f13 | 항상 전송 (`devin_request.go:335`, `devinEncodeCacheOptions`) | 해당 없음 | **없음** (`chat.ts:650-673`) | **OCX 결손** | +| 카탈로그 TTL 캐시 | 없음 | 없음 | 10분 (`catalog.ts:54`) | OCX 우위 | +| `invalid_argument` cooldown 회피 | HTTP 400 재분류 (`devin_executor.go:959-983`) | 해당 없음 | 없음 (`devin.ts:53-64`) | **OCX 결손** | +| tool 설명 절단 | 1024B rune-safe (`devin_tools.go:125-151`) | 해당 없음 | 6998 JS `slice` (`chat.ts:586-587`) | **OCX 결손** (한글 중간 절단) | +| 자격증명 identity 분리 | 요청 스코프 | 해당 없음 | `(host, apiKey)` 싱글톤, 계정 전환 시 미소거 | **OCX 결손** | + +`omp`/`omo`는 Devin 트랜스포트가 아니다. `omp.ts`는 Oh My Pi YAML, `omo`는 senpi +`models.json` + `sendSessionAffinityHeaders`다. 캐싱 비교 대상은 실질적으로 Plus 하나이며, +"Plus보다 낫게"의 정의는 **OCX의 세션 재사용 + Plus의 f13 + Plus에 없는 identity 분리**다. + +## 작업 단계 지도 (의존 순) + +``` +wp0 (이 문서) ──┬── wp1 #4420 carry (독립) + ├── wp2 #4384 carry (독립) + ├── wp3 TTFB 생성 데드라인 (독립, 사용자 실측 버그) + └── wp4 Devin 캐싱/identity (wp3와 같은 파일 → wp3 다음) +``` + +| wp | 문서 | 산출물 | 쓰기 범위 | +|---|---|---|---| +| wp0 | 이 문서 + 010/020/030/040 | 로드맵 | `devlog/_plan/260913_devin_landing_and_caching/` | +| wp1 | `010_wp1_swe2_effort_carry.md` | carry PR → merge | `src/adapters/devin.ts`, `tests/providers/devin-adapter.test.ts`, docs/structure | +| wp2 | `020_wp2_devin_cli_fixture_carry.md` | carry PR → merge | `tests/providers/devin-cli-login.test.ts` | +| wp3 | `030_wp3_ttfb_generation_deadline.md` | 버그픽스 PR → merge | `src/adapters/devin/cloud-direct/chat.ts`, `src/adapters/devin.ts`, 신규 테스트 | +| wp4 | `040_wp4_devin_prompt_cache_and_identity.md` | 기능 PR → merge | `chat.ts` 인코더/세션, `catalog.ts`, `auth.ts`, 신규 테스트 | + +wp1과 wp2는 파일이 겹치지 않는다 (레인 E 확인). wp3과 wp4는 둘 다 `chat.ts`를 +만지므로 순차로 간다. + +## 완료 기준 + +| id | 기준 | 증거 | +|---|---|---| +| c-1 | 이 단위가 000 + 단계별 decade 문서를 diff 수준으로 보유 | 파일 목록 | +| c-2 | #4420 수정이 `dev`에 merge | merge SHA + 트레일러 + CI run id | +| c-3 | #4384 수정이 `dev`에 merge | merge SHA + 트레일러 + CI run id | +| c-4 | TTFB 504가 사라지고 회귀 테스트 존재 | merge SHA + CI run id | +| c-5 | 캐싱 개선이 merge되고 Plus/omp 대조표가 문서화 | merge SHA + 이 문서의 대조표 | +| c-6 | 모든 merge가 exact-final-head hosted CI 성공 | PR별 run id, cancelled/skipped는 성공으로 세지 않음 | + +## 종료 조건 + +- `DONE`: c-1..c-6 전부 충족, 이 단위를 `_fin/`으로 이동. +- `BLOCKED`: fork 푸시 거부로 carry 불가, 또는 동일 head에서 CI 2회 연속 red. + + +## A 단계 감사 결과 (2026-09-13) + +`xai/grok-4.6` 리뷰어 2명을 병렬로 붙여 로드맵 전체를 트리와 대조했다. 두 감사 모두 +`VERDICT: fail`로 돌아왔고, 블로커 4건은 아래처럼 반영했다. + +| 블로커 | 내용 | 반영 | +|---|---|---| +| A-1 | `EFFORT_SUFFIXES`에 `priority` 누락 → `-priority` UID에 접미사 이중 부착 | **wp5 신설** (`050_...md`) | +| A-2 | abort 사유를 `CloudChatError`로 감싸도 `AbortError`에 먹힐 수 있음 | 030 감사 반영 절 (catch에서 명시 throw) | +| B-1 | `clearSessionIds()`가 전역 `Map.clear()`라 타 계정 진행 턴을 끊음 | 040 감사 반영 절 (identity 스코프 + epoch) | +| B-2 | (통과) 필드 13 인코딩 `6a 02 08 01` Plus와 바이트 동일 | 변경 없음 | + +함께 확인된 것: 여섯 개 structure 복붙 hunk를 빼도 `structure:check`는 깨지지 않는다 +(게이트는 경로 존재만 본다). sha256 캐시 키 전환을 깨뜨릴 호출자나 테스트는 없다. + +감사 원문: `.tmp/research/audit-a-facts.md`, `.tmp/research/audit-b-cache.md`. + +## 갱신된 작업 단계 지도 + +``` +wp0 ──┬── wp1 #4420 carry + ├── wp2 #4384 carry + ├── wp3 TTFB 생성 데드라인 + ├── wp4 Devin 캐싱/identity (wp3 다음, 같은 파일) + └── wp5 effort 접미사 통합 (wp1 다음, 같은 함수) +``` + + +## P 단계 수정 — wp6 추가 (2026-09-13, wp1 사이클 진입 시) + +사용자가 `AssignModel` 누락을 지적했다. TTFB 원인으로는 기각됐지만(030 말미 참조 — +Plus도 `devinIsRouterModel` 가드 뒤에서만 부르고 `swe-2-high`는 걸리지 않는다), +라우터 uid를 아예 처리 못 한다는 별개 결손이 확인되어 wp6으로 세웠다. + +| wp | 문서 | 산출물 | +|---|---|---| +| wp6 | `060_wp6_assign_model_router.md` | 라우터 uid용 AssignModel 선행 호출 + 필드 26 | + +``` +wp0 ──┬── wp1 #4420 carry + │ └── wp5 effort 접미사 통합 + ├── wp2 #4384 carry + └── wp3 TTFB 헤더 예산 + └── wp4 프롬프트 캐시 / identity + └── wp6 AssignModel 라우터 +``` + +같은 검증에서 확정된 두 가지도 030에 기록했다: 헤더 이후 구간은 추론 프레임이 +`resetIdle()`을 재무장시켜 이미 안전하고, Plus의 `http.Client{Timeout: 120s}`는 +Go에서 전체 요청 예산이라 정상적인 3분 턴도 자른다 — 따라가지 않는다. + +| id | 기준 | 증거 | +|---|---|---| +| c-7 | effort 접미사 통합 merge | merge SHA + CI run id | +| c-8 | AssignModel 라우터 지원 merge | merge SHA + CI run id | + diff --git a/devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md b/devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md new file mode 100644 index 0000000000..de58567470 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/010_wp1_swe2_effort_carry.md @@ -0,0 +1,116 @@ +# 010 — wp1: #4420 carry (SWE-2 명시 effort가 접미사를 이긴다) + +- 원 PR: https://github.com/lidge-jun/opencodex/pull/4420 (`Smartnewb`, draft) +- 원 head: `6a456fb2af306a2d30a36e2f884c75318d3dd18b`, base `f5b2a0d00` (현재 `dev`보다 27커밋 뒤) +- patch: `.tmp/research/4420.patch` (sha256 `3a03dda5064298c5c156dbed0b14865451967f2f9bbba04f2dacd8d86ae93227`) +- apply 검증: `git apply --check` / `--3way --check` 둘 다 EXIT 0, reject 없음 (레인 E) + +## 왜 아직 필요한가 + +현재 `dev`(`7ca00ffe7`)의 `src/adapters/devin.ts:99-106`: + +```ts +async function resolveWireModelUid( + rawModelId: string, + apiKey: string, + host: string, + reasoningEffort?: string, +): Promise { + const modelId = normalizeDevinModelId(rawModelId); + if (hasEffortSuffix(modelId)) return modelId; // <- 여기서 끝난다 +``` + +`swe-2-high`를 명시 effort `medium`으로 부르면 `hasEffortSuffix`가 참이라 즉시 반환되고, +호출자가 지정한 `medium`은 버려진다. `#4415`가 ACP를 걷어낸 뒤 이 공유 어댑터에는 +SWE-2 재작성 경로가 없다. `rg` 결과 `swe-2` 정규식도, `SWE-2 wire effort selection` +describe도 트리에 없다. + +## MODIFY: src/adapters/devin.ts + +`resolveWireModelUid`를 export하고, `hasEffortSuffix` 조기 반환 **앞에** SWE-2 분기를 넣는다. + +```ts +// before + const modelId = normalizeDevinModelId(rawModelId); + if (hasEffortSuffix(modelId)) return modelId; + +// after + const modelId = normalizeDevinModelId(rawModelId); + const swe2 = resolveSwe2Variant(modelId, reasoningEffort); + if (swe2) return swe2; + if (hasEffortSuffix(modelId)) return modelId; +``` + +신규 헬퍼 (원 PR은 인라인이었다 — grok-bot이 지적한 이중 유지보수를 피해 분리한다): + +```ts +const SWE2_EFFORT: Record = { + none: "medium", off: "medium", minimal: "medium", low: "medium", medium: "medium", + high: "high", + xhigh: "max", ultra: "max", max: "max", +}; + +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; +} +``` + +effort를 안 줬거나 모르는 값이면 `undefined`를 돌려 기존 경로가 그대로 돈다. +`EFFORT_SUFFIXES`(`devin.ts:69`)에 `ultra`/`off`/`minimal`이 없다는 사실은 이 표가 +별도로 필요한 이유이자, 표를 한 곳에 모아야 하는 이유다. + +## MODIFY: tests/providers/devin-adapter.test.ts + +`SWE-2 wire effort selection` describe를 추가한다. 원 PR의 4케이스에 회귀 2건을 더한다. + +| 입력 modelId | reasoningEffort | 기대 UID | +|---|---|---| +| `swe-2-high` | `medium` | `swe-2-medium` | +| `swe-2` | `xhigh` | `swe-2-max` | +| `swe-2-medium` | `high` | `swe-2-high` | +| `swe-2-high` | (없음) | `swe-2-high` | +| `swe-2-high` | `bogus` | `swe-2-high` | +| `gpt-5-6-sol-high` | `medium` | `gpt-5-6-sol-high` (타 계열 불변) | + +import 라인 2를 `resolveWireModelUid` 포함으로 바꾼다. + +## MODIFY: 문서 2개만 + +- `docs-site/src/content/docs/reference/adapters.md` — `devin` 절에 SWE-2 effort 문단 1개 +- `structure/adapters/registry.md` — 소유권 문장 1줄 + +원 PR이 같은 문장을 `structure/data-planes/inbound-compat.md`, +`structure/providers/chat-compat.md`, `structure/providers/cursor.md`, +`structure/runtime.md`, `structure/transports/inventory.md`, +`structure/transports/responses.md` 6곳에 복붙했다. grok-bot 리뷰가 지적한 대로 +structure-gate 인접성을 통과하려는 잡음이므로 **omit**한다. 만약 `structure:check`가 +hosted CI에서 이를 요구하면 그때 되살린다 (CI가 판정자). + +## 커밋 메시지 + +```text +fix(devin): apply explicit SWE-2 effort before model suffix + +Carry #4420 from 6a456fb2af306a2d30a36e2f884c75318d3dd18b onto 7ca00ffe7. +An explicit SWE-2 reasoning effort must win over a picker suffix, so +swe-2-high + medium becomes swe-2-medium before hasEffortSuffix +short-circuits. Omitted or unknown effort keeps the variant; other +families keep suffix precedence. + +The effort map is a named table rather than an inline branch, because +EFFORT_SUFFIXES does not carry ultra/off/minimal and the two would drift. +The six copy-paste structure hunks from the source PR are omitted. + +Local product tests / typecheck / build / install: NOT RUN. +Hosted exact-head CI on this PR is the merge proof. + +Co-authored-by: Smartnewb <159137930+Smartnewb@users.noreply.github.com> +``` + +## 착지 후 + +- #4420을 close하고 carry PR을 가리키는 코멘트를 남긴다 (`dev` 타깃이라 자동 close 안 됨). +- #4416은 이미 closed. ACP는 되살리지 않는다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md b/devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md new file mode 100644 index 0000000000..5497dbb408 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/020_wp2_devin_cli_fixture_carry.md @@ -0,0 +1,66 @@ +# 020 — wp2: #4384 carry (빈 XDG_DATA_HOME 폴백을 호스트 홈에 고정) + +- 원 PR: https://github.com/lidge-jun/opencodex/pull/4384 (`luvs01`, draft) +- 원 head: `fdba29bc1ae1cf262430764221312d729476a551`, base `dcd13b435` (현재 `dev`보다 1커밋 뒤) +- patch: `.tmp/research/4384.patch` (sha256 `662d9993bb262009accc93248338e40960c301a81b8d221656fb0d813f265eaf`) +- apply 검증: `git apply --check` EXIT 0, offset 0, 1파일 + +## 왜 아직 필요한가 + +프로덕션은 이미 맞다. `src/oauth/devin-cli.ts:80`이 빈 `XDG_DATA_HOME`일 때 +`homedir()`로 폴백한다. 깨지는 건 테스트뿐이다. + +`tests/providers/devin-cli-login.test.ts:155`가 결과 경로에 `startsWith("/")`를 건다. +Windows 러너에서 폴백 경로는 `C:\Users\runneradmin\...`이므로 항상 false다. +실제 실패 로그 (fork run, Windows job): + +```text +D:\a\opencodex\opencodex\tests\providers\devin-cli-login.test.ts:155:40 +Expected: true +Received: false +``` + +## MODIFY: tests/providers/devin-cli-login.test.ts + +플랫폼 무관 단언으로 바꾼다. 경로 접두사를 문자열로 추측하지 말고 호스트 홈에 고정한다. + +```ts +// before +expect(resolved.startsWith("/")).toBe(true); + +// after +expect(resolved.startsWith(homedir())).toBe(true); +``` + +`homedir`는 `node:os`에서 import한다. 이것이 프로덕션 코드가 실제로 하는 일 +(`src/oauth/devin-cli.ts:80`)과 정확히 같은 계약이므로, 테스트가 구현을 복제하는 것이 +아니라 계약을 검증하게 된다. + +## 범위 밖 + +같은 Windows job에 quota-policy 실패 2건이 함께 있었다. `#4384`의 범위가 아니며 +이 carry에서 건드리지 않는다. 별도 단위로 남긴다. + +## 커밋 메시지 + +```text +test(devin-cli): anchor the empty-data-dir fallback at the host home + +Carry #4384 from fdba29bc1ae1cf262430764221312d729476a551 onto 7ca00ffe7. +The empty-XDG_DATA_HOME case asserted the resolved path starts with "/", +which is false on Windows where the fallback is C:\Users\\... +Anchor the assertion at homedir() instead, which is the contract +src/oauth/devin-cli.ts actually implements. + +Production behavior is unchanged; this is a test-only fix. + +Local product tests / typecheck / build / install: NOT RUN. +Hosted exact-head CI on this PR is the merge proof. + +Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> +``` + +## 착지 후 + +- #4384를 close하고 carry PR을 가리키는 코멘트를 남긴다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md b/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md new file mode 100644 index 0000000000..62507f04c3 --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/030_wp3_ttfb_generation_deadline.md @@ -0,0 +1,253 @@ +# 030 — wp3: TTFB 타임아웃이 살아 있는 업스트림을 죽인다 + +사용자가 실제로 맞은 오류다. + +```text +stream disconnected before completion: cloud-direct: time-to-first-byte timeout (60000ms) +``` + +## 근본 원인 + +`src/adapters/devin/cloud-direct/chat.ts:50`: + +```ts +/** Time-to-first-byte timeout. */ +const CLOUD_STREAM_TTFB_MS = 60_000; +``` + +`chat.ts:1118-1131`의 주석은 이렇게 주장한다. + +> Once any byte arrives we cancel the TTFB timer and start the per-chunk idle timer + +그런데 실제 코드는 그렇지 않다. 타이머는 `chat.ts:1152-1153`의 `finally`에서 지워지고, +그 `finally`는 `await fetch(...)`가 **응답 헤더**로 resolve될 때 실행된다. 즉 이 60초는 +"첫 바이트"가 아니라 "헤더 도착"까지의 예산이다. 본문 첫 토큰은 보지 않는다. + +본문 무응답은 이미 별도 예산이 있다 — `CLOUD_STREAM_IDLE_MS = 120_000` (`chat.ts:48`, +`chat.ts:1246`). 그래서 현재 구조는 **헤더 60초 < 본문 idle 120초**로, 오래 생각하는 +모델일수록 관대해야 할 구간이 더 빡빡하다. + +Cognition은 SWE-2에서 첫 토큰이 나올 때까지 헤더를 붙들어 둔다. 그래서 effort가 높을수록 +헤더가 늦고, 우리가 스스로 끊는다. + +## 라이브 증거 + +사용자 로그에서 3건, 전부 같은 모양이다. + +| # | provider | model | effort | status | durationMs | firstOutputMs | attempts | +|---|---|---|---|---|---|---|---| +| 1 | `devin-cli` | `swe-2` | high | 504 | 60000 | 없음 | 1 | +| 2 | `devin-cli` | `swe-2` | high | 504 | 60024 | 없음 | 1 | +| 3 | `devin-cli` | `swe-2` | high | 504 | 55968 | 없음 | 1 | + +`service.log:66219`에 REJECTED `GetChatMessage` `ageMs=137197`이 있고, 같은 시각 형제 +호출은 76초까지 살아남았다. 업스트림은 죽지 않았다. 우리가 먼저 끊었다. + +## 두 번째 결함: 오류 분류가 비어 있다 + +abort 사유는 raw `Error`다 (`chat.ts:1123`). `CloudChatError`가 아니므로 +`devinErrorClassification`(`src/adapters/devin.ts:53-55`)이 `status === undefined`로 +`{}`를 반환하고, 분류가 `src/lib/errors.ts:429-435`의 문자열 추론으로 떨어져 +`504 upstream_server_error`가 된다. 우리 쪽 데드라인인데 업스트림 장애로 보고된다. + +## 세 번째 결함: `timeout: 0`이 없다 + +`chat.ts:1135`의 raw `fetch`에는 Bun 자체 fetch 타임아웃을 끄는 `timeout: 0`이 없다. +하우스 스타일은 `src/server/responses/fetch-helpers.ts:87`이다. + +```ts +const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }; +``` + +## MODIFY: src/adapters/devin/cloud-direct/chat.ts + +### 1) 헤더 예산을 생성 데드라인과 분리하고 설정 가능하게 + +```ts +// before +/** Time-to-first-byte timeout. */ +const CLOUD_STREAM_TTFB_MS = 60_000; + +// after +/** + * Time-to-response-headers budget. Cognition holds the response headers until + * the model produces its first token, so on a high-effort reasoning model this + * is a generation deadline, not a connect timeout. It must therefore be at + * least as generous as the idle budget below; a 60s value guillotined live + * swe-2 high turns at 60000ms with no output (three logged 504s, 2026-09-13). + * Override with OPENCODEX_DEVIN_TTFB_MS. + */ +const CLOUD_STREAM_TTFB_DEFAULT_MS = 300_000; +function cloudStreamTtfbMs(): number { + const raw = process.env.OPENCODEX_DEVIN_TTFB_MS?.trim(); + if (!raw) return CLOUD_STREAM_TTFB_DEFAULT_MS; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : CLOUD_STREAM_TTFB_DEFAULT_MS; +} +``` + +`OPENCODEX_ACL_TIMEOUT_MS`(`src/lib/windows-secret-acl.ts:271`)와 같은 하우스 패턴이다. + +### 2) abort를 분류 가능한 오류로 감싼다 + +```ts +// before +const ttfbTimer = setTimeout(() => ttfbController.abort(new Error(`cloud-direct: time-to-first-byte timeout (${CLOUD_STREAM_TTFB_MS}ms)`)), CLOUD_STREAM_TTFB_MS); + +// after +const ttfbMs = cloudStreamTtfbMs(); +const ttfbTimer = setTimeout( + () => ttfbController.abort(new CloudChatError(`cloud-direct: no response headers within ${ttfbMs}ms`, undefined, undefined, 504)), + ttfbMs, +); +``` + +메시지에서 "time-to-first-byte"라는 말을 뺀다. 헤더를 기다린 것이지 바이트가 아니다. + +### 3) `timeout: 0` 추가 + +```ts + resp = await fetch(url, { + method: "POST", + headers: { /* unchanged */ }, + body, + redirect: "error", + signal: initialSignal, + timeout: 0, + } as RequestInit); +``` + +### 4) 주석의 거짓말을 고친다 + +`chat.ts:1118-1121`의 "Once any byte arrives"는 사실이 아니다. "Once the response +headers arrive"로 정정한다. 이 주석이 이 버그를 가려 왔다. + +## NEW: tests/adapters/devin/cloud-direct-stream-deadline.test.ts + +fake `fetch` + fake timer로 시간을 실제로 흘려보내지 않는다. + +| 케이스 | 시나리오 | 기대 | +|---|---|---| +| A | 헤더가 기본 예산을 넘겨 안 옴 | `CloudChatError`, `status === 504`, 메시지에 `no response headers` | +| B | 헤더 90초 뒤 도착, 본문 정상 | **성공** (구 코드에서는 60초에 죽음) — 사용자 버그의 회귀 | +| C | 헤더 즉시, 본문 idle 초과 | 기존 idle 오류 유지 | +| D | `OPENCODEX_DEVIN_TTFB_MS=1000` | 1초에 abort (설정 반영) | +| E | fetch init에 `timeout: 0` 포함 | 캡처한 init 단언 | +| F | abort 오류가 `devinErrorClassification`에서 `status:504`, `retryable:true` | 분류 회귀 | + +케이스 B가 이 단계의 존재 이유다. + +## 하지 않는 것 + +프록시 레벨 자동 재시도는 넣지 않는다. Codex가 이미 같은 conversation으로 재시도했고, +생성 데드라인을 늘리는 것과 재시도는 다른 문제다. 재시도를 넣으면 토큰을 두 번 태운다. + + +## 감사 반영 (A 단계, BLOCKER-2) + +`ttfbController.abort(new CloudChatError(...))`로 abort 사유를 감싸는 설계는 취소한다. +`fetch`가 `signal.reason`을 그대로 던진다는 보장이 없다. 런타임에 따라 `AbortError`로 +감싸서 던지면 우리 `CloudChatError`는 사라지고 030 케이스 F가 실패한다. + +대신 abort 사유는 평범하게 두고, `fetch`를 감싼 `catch`에서 **우리 타이머가 발화했는지**를 +보고 명시적으로 던진다. 그래야 런타임 동작에 의존하지 않는다. + +```ts +let ttfbFired = false; +const ttfbMs = cloudStreamTtfbMs(); +const ttfbTimer = setTimeout(() => { + ttfbFired = true; + ttfbController.abort(); +}, ttfbMs); + +let resp: Response; +try { + resp = await fetch(url, { /* ... */ signal: initialSignal, timeout: 0 } as RequestInit); +} catch (err) { + if (ttfbFired) { + // Our deadline, not the upstream failing. Classify it as ours so + // devinErrorClassification sees a status instead of returning {}. + throw new CloudChatError(`cloud-direct: no response headers within ${ttfbMs}ms`, undefined, undefined, 504); + } + throw err; +} finally { + clearTimeout(ttfbTimer); + composed?.cleanup(); +} +``` + +케이스 F는 이 `catch` 경로를 직접 겨냥한다. 케이스 G를 추가한다: 호출자가 자기 +`req.signal`로 취소했을 때는 `ttfbFired`가 false라 원래 abort가 그대로 전파된다. + +감사에서 함께 확인된 것: 기본값 300000ms는 `timeout: 0`과 같이 가면 안전하고, +본문 침묵은 그대로 120초 idle이 잡는다. 라이브 `ageMs=137197` 사례가 있으므로 +120000은 헤더 예산으로 부족하다. + + +## 사용자 지적 반영 — AssignModel 가설 검증 (2026-09-13, wp1 P 시점) + +사용자가 계획의 약한 곳을 짚었다: 네이티브 CLI와 Plus는 `AssignModel` RPC를 먼저 +부르는데 우리만 안 부른다. 라우팅 비용을 짧은 별도 호출로 치르지 않고 생성 요청에 +묻어버려서 헤더가 늦는 것 아니냐는 가설이다. + +절반은 사실이고, TTFB 원인으로는 **기각된다**. + +### 사실인 부분 + +우리 트리에 `AssignModel`이 없다. `rg -in "assignmodel|assignment_jwt" src/ tests/` 결과가 +0건이다. Plus는 `devin_request.go:376`에 +`devinAssignModelPath = "/exa.api_server_pb.ApiServerService/AssignModel"`를 두고 +`devin_executor.go:641,765`에서 부른 뒤 결과 `ModelUID`와 `AssignmentJWT`(필드 26)를 +`GetChatMessage`에 싣는다. + +### 기각되는 부분 + +Plus의 호출은 무조건이 아니라 가드 뒤에 있다 (`devin_executor.go:883-885`): + +```go +// devinIsRouterModel reports whether a model id routes through AssignModel. +// Thinking-effort suffixes are resolved server side. +func devinIsRouterModel(model string) bool { + return strings.HasSuffix(model, "-router") || strings.Contains(model, "model-router") +} +``` + +`swe-2-high`는 `-router`로 끝나지도, `model-router`를 포함하지도 않는다. 그러니 Plus도 +이 모델에서는 `AssignModel`을 부르지 않고 곧장 `GetChatMessage`로 간다 — 우리와 같다. +주석이 직접 못을 박는다: **thinking-effort 접미사는 서버가 푼다.** + +따라서 `AssignModel` 누락은 사용자가 실제로 맞은 `swe-2-high` 504의 원인이 아니다. +wp3의 헤더 예산 수정은 그대로 간다. + +### 그래도 남는 진짜 결손 → wp6 + +기각됐다고 가치가 없는 건 아니다. `AssignModel`이 없으면 **라우터 uid를 아예 못 쓴다.** +레인 A가 카탈로그에서 `adaptive`를 확인했고, 우리 `src/`에는 `adaptive`도 `router`도 +0건이다(`live-models.ts`, `devin.ts` 검색). 사용자가 라우터 모델을 고르면 우리는 그것을 +구체 uid로 바꾸지 못한 채 원시 문자열로 보낸다. + +이것은 TTFB와 무관한 별개 기능 결손이므로 **wp6**으로 세운다. 측정이 필요한 가설 +(핸드셰이크가 헤더 지연을 줄이는가)이 아니라, 확인된 기능 공백이다. + +### 함께 확정된 것 두 가지 + +**헤더 이후는 이미 안전하다.** 추론 프레임이 생존 신호로 동작한다. 파서가 추론을 +`kind: reasoning`으로 분리하고(`chat.ts:427,753`), `resetIdle()`이 `reader.read()`가 +무엇이든 돌려주면 재무장한다. 헤더만 도착하면 그 뒤 90초를 생각해도 죽지 않는다. +죽는 구간은 오직 헤더 이전이다. wp3가 그 한 구간만 건드리는 것이 맞다. + +**Plus의 타임아웃은 따라가면 안 된다.** `devin_executor.go:69,85`: + +```go +devinDefaultTimeout = 120 * time.Second +client: &http.Client{Timeout: devinDefaultTimeout}, +``` + +Go의 `http.Client.Timeout`은 헤더가 아니라 본문 읽기까지 포함한 **전체 요청** 예산이다. +3분짜리 정상 스트리밍 턴도 120초에 잘린다. 긴 턴에 대해서는 우리 구조(헤더 예산과 +본문 idle 분리)가 오히려 낫다. 고칠 곳은 헤더 구간 하나다. + +헤더 데드라인을 길게 잡는 것이 위험하지 않은 이유도 여기 있다. 업스트림이 죽으면 +TCP/HTTP2 레벨 오류가 즉시 올라와 `fetch`가 reject된다. 300초를 조용히 기다리는 +경우는 연결이 블랙홀이 된 때뿐이고, 그건 keepalive의 영역이다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md b/devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md new file mode 100644 index 0000000000..fef239d0aa --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/040_wp4_devin_prompt_cache_and_identity.md @@ -0,0 +1,215 @@ +# 040 — wp4: Devin 프롬프트 캐시와 자격증명 identity + +목표는 "Plus와 omp보다 훨씬 좋게"다. 조사 결과 그 목표가 구체적으로 무엇인지가 +분명해졌다: 세 구현이 각자 다른 반쪽을 갖고 있고, 아무도 전부를 갖고 있지 않다. + +| 능력 | 네이티브 CLI | Plus | opencodex 오늘 | wp4 이후 | +|---|---|---|---|---| +| 세션/캐스케이드 재사용 | 있음 (`sessions.db`) | **없음** (매 요청 새로) | 있음 | 있음 | +| 프롬프트 캐시 옵션 f13 | 있음 (원격) | 있음 (항상) | **없음** | 있음 | +| 캐시 identity 격리 | 있음 (`identity_digest`) | 요청 스코프라 무관 | **없음** (계정 누수) | 있음 | +| 카탈로그 TTL | 없음 | 없음 | 있음 (10분) | 있음 | +| `invalid_argument` cooldown 회피 | 해당 없음 | 있음 | **없음** | 있음 | +| rune-safe tool 절단 | 해당 없음 | 있음 (1024B) | **없음** (JS slice) | 있음 | + +## 근거 1 — 우리는 프롬프트 캐시를 아예 요청하지 않는다 + +`chat.ts:54-60`의 주석은 세션 재사용이 "prompt-cache hit ratio"를 살린다고 말한다. +그런데 요청 인코더(`chat.ts:650-673`)가 실제로 쓰는 필드는 1, 2, 3, 7, 8, 10, 15, 16, +20, 21뿐이다. 캐시 옵션 필드가 없다. + +Plus는 매 요청에 넣는다 (`internal/runtime/executor/devin_request.go:23,35,335`): + +```go +devinReqCacheOptionsField = 13 +devinCacheControlEphemeral = 1 + +// devinEncodeCacheOptions encodes PromptCacheOptions{type: EPHEMERAL}. +// +// The native client marks the system prompt as an ephemeral cache entry, which +// is what makes prompt caching effective across turns. +func devinEncodeCacheOptions() []byte { + return devinEncodeField(nil, 1, 0, devinEncodeVarint(nil, devinCacheControlEphemeral)) +} +``` + +네이티브 바이너리도 원격 프롬프트 캐시를 쓴다 — `prompt_cache_key_base`, +`disable_prompt_cache_writes`, `system_prefix_len`, `append_only_history` 필드와 +`affogato/src/cache_keepalive.rs`의 `[CACHE_KEEPALIVE] Ping sent`(TTL clamp 1–60s). +`~/.local/share/devin/cli/sessions.db`의 assistant 지표에 `cache_read_tokens`가 +42808, 59704처럼 실제로 찍혀 있다. 캐시는 동작하고, 값이 크다. + +즉 Plus는 캐시 옵션은 보내지만 세션을 매번 버려서(`devin_executor.go:626-637`) 캐시 +키가 흩어지고, 우리는 세션은 지키는데 캐시 옵션을 안 보낸다. 둘 다 반쪽이다. + +## 근거 2 — 캐시가 계정 사이로 샌다 + +`chat.ts:73`의 키는 `${host}\x1f${apiKey}`다. JWT 캐시(`auth.ts:191-214`)와 +카탈로그 캐시(`catalog.ts:54`)도 같은 `(host, apiKey)` 싱글톤이다. 그런데: + +- `clearSessionIds`(`chat.ts:94`)는 export되어 있지만 **호출하는 곳이 없다**. +- `src/server/management/oauth-account-routes.ts:349-374`의 logout/remove만 JWT와 + 카탈로그를 지우고, 계정 **전환**은 cloud-direct 캐시를 건드리지 않는다. +- `devin.ts:273-318`의 cascade-id Map은 스레드 키라 logout 후 새 계정에 이전 + cascade가 그대로 붙는다. + +네이티브 CLI는 이 문제를 이미 풀어 뒀다. `~/.cache/devin/cli/*.bin` 봉투가 +`identity_digest`를 갖고, 다른 identity로 쓰인 캐시는 거부한다: +`Ignoring cache file : written under a different identity`. + +## MODIFY: src/adapters/devin/cloud-direct/chat.ts — 캐시 옵션 필드 + +`buildChatRequest`의 `Buffer.concat` 배열에 필드 13을 추가한다. 필드 번호 순서상 +`encodeMessage(10, ...)` 토큰들과 `encodeMessage(15, ...)` 사이다. + +```ts +// after ...toolParts, + // #13 prompt_cache_options: { type: EPHEMERAL }. The native client marks the + // system prefix as an ephemeral cache entry; without it the server does not + // create a cache entry at all and every turn re-reads the full prefix. + // Verified against the native CLI (cache_read_tokens 42808/59704 in + // ~/.local/share/devin/cli/sessions.db) and CLIProxyAPIPlus + // devin_request.go:335 devinEncodeCacheOptions. + encodeMessage(13, encodeVarintField(1, PROMPT_CACHE_EPHEMERAL)), +``` + +`const PROMPT_CACHE_EPHEMERAL = 1;`를 파일 상단 상수와 함께 둔다. + +## MODIFY: chat.ts / auth.ts / catalog.ts — identity 키 + +세 캐시가 같은 identity 개념을 공유하게 한다. 자격증명 원문을 키로 쓰지 않는다. + +```ts +/** + * Cache identity for a Devin credential. The native CLI stores an + * identity_digest beside every cache envelope and refuses an entry written + * under a different identity; without that, switching accounts silently + * serves the previous account cached session, catalog and JWT. + * The digest never contains the credential itself. + */ +export function devinCacheIdentity(apiKey: string, host: string): string { + const digest = createHash("sha256").update(`${host}\x1f${apiKey}`).digest("hex"); + return digest.slice(0, 16); +} +``` + +`getOrAllocateSessionIds`, JWT 캐시, 카탈로그 캐시가 모두 이 값을 키로 쓴다. +해시로 바꾸는 것 자체가 부수 이득이다 — 지금은 Map 키에 API 키 원문이 들어 있고, +힙 덤프나 디버거에 그대로 노출된다. + +## MODIFY: 계정 전환 시 소거 (identity 스코프 전용) + +전역 `clearSessionIds`는 **삭제한다**. 남겨 두면 함정이다. +`oauth-account-routes.ts:281`의 per-provider logout이 그것을 부르는 순간 다른 계정의 +진행 중인 턴까지 session/cascade를 잃는다. 지금까지 호출자가 0건이었던 이유가 +그것이며, 안전하게 부를 수 있는 자리가 애초에 없다. + +대신 identity 스코프 무효화 하나만 남긴다. + +| 경로 | 호출 | +|---|---| +| per-provider logout / remove | `invalidateSessionIdentity(devinCacheIdentity(apiKey, host))` | +| 계정 전환 (activate/select) | 같음, 떠나는 identity에 대해 | +| 전체 종료 | 없음 — 프로세스가 사라지면 Map도 사라진다 | + +`devin.ts:273`의 cascade Map도 같은 identity 기준으로 해당 항목만 버린다. + +단위 테스트로 고정한다: + +- 계정 A로 한 턴 → 계정 B로 전환 → B의 요청이 A의 sessionId/cascadeId를 재사용하지 않는다. +- 계정 A의 턴이 **진행 중**일 때 B를 로그아웃해도 A의 턴은 자기 sessionId를 유지한다. +- 전역 소거 함수가 존재하지 않는다 (export 표면 회귀). + +## MODIFY: src/adapters/devin.ts — `invalid_argument`가 cooldown을 태우지 않게 + +Plus는 `invalid_argument`를 HTTP 400으로 재분류해 자격증명 cooldown을 건너뛴다 +(`devin_executor.go:959-983`, `devin_cooldown_test.go:9-15`). 우리 `devinErrorClassification` +(`devin.ts:53-64`)에는 그 분기가 없어서, 우리가 만든 잘못된 요청 하나가 멀쩡한 +자격증명을 식힌다. + +```ts +if (status === 400) return { status, errorType: "invalid_request_error", retryable: false }; +``` + +와 함께 Connect trailer `invalid_argument`를 400으로 매핑한다. + +## MODIFY: rune-safe tool 설명 절단 + +`chat.ts:586-587`은 JS `slice(0, 6998)`이다. UTF-16 코드 유닛 기준이라 한글이나 +이모지 중간에서 잘리고, 그 결과가 `invalid_argument: an internal error occurred`다. +Plus는 1024바이트 rune-safe 절단을 쓴다(`devin_tools.go:125-151`). + +바이트 예산으로 바꾸고 코드포인트 경계에서 자른다. 한국어로 도구를 설명하는 +사용자에게 직접 영향이 있다. + +## MODIFY: usage 회계에 캐시 읽기를 노출 + +field 7 `ModelUsageStats`가 과금 권위다(`chat.ts:927-959`). 네이티브가 +`cache_read_tokens`를 기록하므로 우리도 파싱해 usage에 싣는다. `cache_creation_tokens`는 +네이티브 실측에서 전부 null이라 기대하지 않는다. + +## NEW: tests/adapters/devin/cloud-direct-prompt-cache.test.ts + +| 케이스 | 기대 | +|---|---| +| 인코딩된 요청에 필드 13이 존재하고 값이 EPHEMERAL | 바이트 단언 | +| 같은 identity의 두 턴이 같은 sessionId/cascadeId | 재사용 회귀 | +| identity가 다르면 새 sessionId | 계정 누수 회귀 | +| logout 후 해당 identity만 소거 | `invalidateSessionIdentity` 회귀, 타 identity 생존 | +| 400/`invalid_argument`가 `retryable:false`, cooldown 없음 | 분류 회귀 | +| 7000바이트 한글 도구 설명이 유효한 UTF-8로 절단 | rune-safe 회귀 | + +## 하지 않는 것 + +- 추론 토큰이나 응답 본문을 로컬 디스크에 캐시하지 않는다 (레인 A 권고). +- 캐시 keepalive ping은 이번 범위에서 제외한다. 네이티브는 하지만 프록시가 사용자 + 턴 밖에서 업스트림을 두드리는 것은 별도 결정이 필요하다. 후속 단위로 남긴다. +- `sessions.db` 같은 로컬 SQLite 세션 저장소는 만들지 않는다. 프로세스 내 Map으로 + 충분하고, 디스크 상태는 계정 누수 표면을 넓힌다. + + +## 감사 반영 (A 단계, BLOCKER-3) + +`clearSessionIds()`를 그대로 부르면 안 된다. 구현이 `sessionCache.clear()`(`chat.ts:95`)라 +전역 소거다. 계정을 전환하는 순간 **다른 계정의 진행 중인 턴**까지 session/cascade를 +잃는다. 프록시는 멀티테넌트이므로 이건 새 버그를 만드는 수정이다. + +identity 스코프 삭제로 바꾼다. + +```ts +/** + * Drop cached IDs for ONE identity. A global clear() would strip the session + * and cascade of every other account mid-turn, which is why the old exported + * clearSessionIds() was never safe to call and consequently never called. + */ +export function invalidateSessionIdentity(identity: string): void { + sessionCache.delete(identity); +} +``` + +동시성은 epoch로 막는다. 캐시 엔트리에 `epoch`를 달고, 요청 시작 시 읽은 epoch와 +응답 조립 시점의 epoch가 다르면 그 턴은 캐시를 갱신하지 않는다. 진행 중인 턴은 +자기 sessionId로 끝까지 가고, 다음 턴부터 새 identity를 쓴다. + +```ts +interface SessionIds { sessionId: string; cascadeId: string; epoch: number; } +``` + +재감사(near-pass)가 남긴 잔여 지적을 반영해, 기존 `clearSessionIds`는 남기지 않고 +**제거한다**. "전체 로그아웃 전용"으로 문서화만 하는 안은 함정이 그대로 남는다 — +`oauth-account-routes.ts:281`의 per-provider logout이 그것을 부르면 타 계정의 진행 중인 +턴이 끊긴다. 안전한 호출 지점이 없는 함수는 export 표면에서 없애는 것이 맞다. +계정 전환과 로그아웃 모두 `invalidateSessionIdentity` 하나만 쓴다. + +테스트에 케이스를 하나 더 넣는다: 계정 A의 턴이 진행 중일 때 계정 B로 전환해도 +A의 턴은 자기 sessionId를 유지한다. + +## 감사에서 통과한 항목 + +가장 위험했던 와이어 포맷은 확인됐다. `encodeMessage(13, encodeVarintField(1, 1))`은 +Plus의 Go 인코더와 바이트가 같다 — `6a 02 08 01`. 필드 13, wire type 2(length-delimited), +길이 2, 내부 필드 1 varint 1. `PromptCacheOptions{type: EPHEMERAL}`에 정확히 맞는다. + +sha256 캐시 키 전환도 안전하다. 그 키를 파싱하거나 재구성하는 호출자나 테스트가 없다. +이 계획에 자격증명을 로그·파일명·오류 메시지에 넣는 단계도 없다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md b/devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md new file mode 100644 index 0000000000..e7e03c6e2c --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/050_wp5_effort_suffix_unification.md @@ -0,0 +1,79 @@ +# 050 — wp5: effort 접미사 집합이 두 벌로 갈라져 있다 + +A 단계 감사(BLOCKER-1)가 찾아낸, 로드맵이 놓쳤던 버그다. wp1과 같은 함수를 건드리지만 +별개 결함이라 별도 PR로 간다. + +## 증상 + +같은 저장소에 effort 접미사 목록이 두 벌 있고, 서로 다르다. + +`src/adapters/devin.ts:69` — 요청 경로: + +```ts +const EFFORT_SUFFIXES = new Set(["low", "medium", "high", "xhigh", "max", "none", "1m", "max-1m", "none-1m", "fast"]); +``` + +`src/adapters/devin/live-models.ts:75-77` — 카탈로그/피커 경로: + +```ts +const EFFORT_TOKENS = new Set([ + "low", "medium", "high", "xhigh", "max", "none", "fast", "priority", "1m", +]); +``` + +`priority`가 한쪽에만 있다. 레인 A가 네이티브 바이너리에서 확인한 실제 카탈로그 +접미사에는 `-priority`가 있고(`gpt-5-6-sol-medium-priority`), `collapseDevinModelUid`는 +이미 그것을 접미사로 취급해 벗겨낸다. + +## 결과 + +`hasEffortSuffix("gpt-5-6-sol-medium-priority")`는 마지막 토큰 `priority`가 +`EFFORT_SUFFIXES`에 없으므로 **false**다. 그러면 `resolveWireModelUid`가 이미 완전한 +UID에 또 접미사를 붙인다. 카탈로그가 없는 degraded 모드에서는 +`gpt-5-6-sol-medium-priority-medium`이 되고, Cognition은 이를 opaque한 +`permission_denied`로 거절한다 — `normalizeDevinModelId` 주석(`devin.ts:71-77`)이 +경고하는 바로 그 실패 모양이다. + +`max-1m` / `none-1m`은 `EFFORT_SUFFIXES`에만 있는데, 이들은 하이픈을 포함하므로 +마지막 토큰만 보는 `hasEffortSuffix`로는 애초에 매칭되지 않는다. 죽은 항목이다. + +## MODIFY: 집합을 하나로 + +`EFFORT_TOKENS`를 단일 출처로 삼고 `devin.ts`가 그것을 import한다. 두 벌을 유지하는 한 +다음 접미사가 추가될 때 같은 드리프트가 반복된다. + +```ts +// src/adapters/devin/live-models.ts +export const EFFORT_TOKENS = new Set([ + "low", "medium", "high", "xhigh", "max", "none", "fast", "priority", "1m", +]); + +// src/adapters/devin.ts +import { EFFORT_TOKENS, collapseDevinModelUid } from "./devin/live-models.js"; + +function hasEffortSuffix(modelId: string): boolean { + return collapseDevinModelUid(modelId) !== modelId; +} +``` + +`collapseDevinModelUid`로 위임하면 다중 접미사(`-medium-priority`)도 자동으로 맞는다. +마지막 토큰 하나만 보는 현재 구현의 한계가 사라진다. + +`resolveWireModelUid`의 effort 검증(`devin.ts:110,119`)도 `EFFORT_TOKENS`를 쓴다. +`max-1m`/`none-1m`은 죽은 항목이므로 제거하되, 커밋 본문에 왜 죽었는지 남긴다. + +## NEW 테스트 + +| 입력 | 기대 | +|---|---| +| `gpt-5-6-sol-medium-priority` | 그대로 (접미사 재부착 없음) | +| `gpt-5-6-sol-priority` | 그대로 | +| `gpt-5-6-sol` + effort `medium` | `gpt-5-6-sol-medium` (기존 동작 불변) | +| `swe-2-high` | wp1의 SWE-2 경로와 충돌 없음 | +| degraded 모드에서 `...-priority` | 이중 접미사 없음 (회귀) | + +## 순서 + +wp1이 먼저 착지한 뒤에 간다. 둘 다 `resolveWireModelUid`를 건드리므로 순차로 처리해 +충돌을 피한다. + diff --git a/devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md b/devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md new file mode 100644 index 0000000000..a2e6ab368f --- /dev/null +++ b/devlog/_plan/260913_devin_landing_and_caching/060_wp6_assign_model_router.md @@ -0,0 +1,100 @@ +# 060 — wp6: 라우터 모델을 못 쓴다 (AssignModel 부재) + +사용자 지적에서 나온 단계다. 원래 가설은 "AssignModel을 안 불러서 헤더가 늦다"였고 +그건 기각됐지만(030 참조), 검증 과정에서 별개의 확정된 기능 결손이 드러났다. + +## 결손 + +우리 트리에 `AssignModel`이 없다. + +```text +$ rg -in "assignmodel|assignment_jwt|assignmentJwt" src/ tests/ +(0건) +$ rg -in "adaptive|router" src/adapters/devin/live-models.ts src/adapters/devin.ts +(0건) +``` + +레인 A가 네이티브 카탈로그에서 `adaptive`를 확인했다. 라우터 uid는 그 자체로 모델이 +아니라 "서버가 골라 달라"는 요청이며, `AssignModel`로 구체 uid를 받아 와야 한다. +우리는 그 문자열을 그대로 `GetChatMessage`의 필드 21에 실어 보내고, Cognition은 +모르는 모델로 취급한다. + +## 언제 부르는가 + +무조건이 아니다. Plus의 가드를 그대로 따른다 (`devin_executor.go:883-885`). + +```go +func devinIsRouterModel(model string) bool { + return strings.HasSuffix(model, "-router") || strings.Contains(model, "model-router") +} +``` + +주석이 명시한다: thinking-effort 접미사는 서버가 푼다. 그러니 `swe-2-high` 같은 평범한 +모델에 이 호출을 붙이면 **왕복만 하나 늘어난다.** 라우터 uid에서만 부른다. + +우리 판정에는 `adaptive`도 넣는다. 레인 A가 카탈로그에서 실제로 본 값이고, Plus의 +접미사 규칙만으로는 걸리지 않는다. + +## NEW: src/adapters/devin/cloud-direct/assign-model.ts + +와이어 포맷은 Plus의 인코더/파서와 1:1로 맞춘다. + +요청 `AssignModelRequest` — 경로 `/exa.api_server_pb.ApiServerService/AssignModel`: + +| 필드 | 내용 | 출처 | +|---|---|---| +| 1 | metadata (GetChatMessage와 동일 빌더) | `devinAssignMetadataField` | +| 2 | router uid (string) | `devinAssignRouterField` | +| 3 | cascade_id (string, 있을 때만) | `devinAssignCascadeField` | +| 5 | 마지막 turn의 prompt 하나만 | `devinAssignPromptField` | + +전체 히스토리가 아니라 **마지막 메시지 하나**만 보낸다는 점이 중요하다. 라우팅 결정에 +필요한 최소치이고, 이래야 이 호출이 짧게 끝난다. + +응답 `AssignModelResponse`: + +| 필드 | 내용 | +|---|---| +| 1 | assignment (sub-message) | +| 1.1 | assignment JWT (string) | +| 1.2 | 구체 model uid (string) | + +## MODIFY: src/adapters/devin/cloud-direct/chat.ts + +라우터 uid일 때만 선행 호출하고, 결과를 두 곳에 반영한다. + +```ts +if (isRouterModelUid(req.modelUid)) { + const assignment = await assignModel(req, sessionIds.cascadeId); + if (assignment?.modelUid) req = { ...req, modelUid: assignment.modelUid }; + if (assignment?.jwt) assignmentJwt = assignment.jwt; +} +``` + +인코더에 필드 26(`assignment_jwt`)을 추가한다. 있을 때만 쓴다. + +```ts +...(assignmentJwt ? [encodeString(26, assignmentJwt)] : []), +``` + +실패는 치명적이지 않다. Plus도 실패하면 요청받은 모델로 그냥 진행한다 +(`devin_executor.go:871` debug 로그 후 fallthrough). 같은 방식으로 degrade한다 — +라우팅을 못 받았다고 턴을 죽이지 않는다. + +## NEW: tests/adapters/devin/cloud-direct-assign-model.test.ts + +| 케이스 | 기대 | +|---|---| +| `swe-2-high` | `AssignModel` 호출 없음 (왕복 추가 금지 회귀) | +| `*-router` / `model-router` / `adaptive` | 호출 있음 | +| 응답의 uid가 필드 21에 반영 | 바이트 단언 | +| 응답의 JWT가 필드 26에 반영 | 바이트 단언 | +| JWT 없으면 필드 26 부재 | 바이트 단언 | +| `AssignModel`이 실패해도 원래 uid로 진행 | degrade 회귀 | +| 요청 필드 5에 마지막 turn 하나만 | 히스토리 유출 회귀 | + +## 순서 + +wp4 다음. 둘 다 `chat.ts` 인코더를 건드리고, wp4의 필드 13이 먼저 들어가는 편이 +필드 순서를 한 번만 정리한다. + diff --git a/tests/ci-workflows/repo-hygiene.test.ts b/tests/ci-workflows/repo-hygiene.test.ts index 6e1733384d..21c3df6423 100644 --- a/tests/ci-workflows/repo-hygiene.test.ts +++ b/tests/ci-workflows/repo-hygiene.test.ts @@ -133,6 +133,7 @@ describe("devlog is tracked, with no submodule left behind", () => { path.startsWith("devlog/_chase/_litellm/") || path.startsWith("devlog/_chase/_cca/") || path.startsWith("devlog/_chase/DSCodex/") + || path.startsWith("devlog/_chase/CLIProxyAPIPlus/") || path.startsWith("devlog/_fin/opencode-cursor/"), );