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
20 changes: 20 additions & 0 deletions devlog/210_pr8-autostart-ensure-review/00_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 210 — PR #8 Code Review: Autostart Ensure Fallback

## Summary

PR #8 (`codex/auto-start-proxy-port-fallback`)의 코드리뷰 결과를 정리하고,
리파인된 피드백을 PR 코멘트로 제출한다. 직접 코드 수정은 하지 않는다.

## Deliverables

| # | Type | Path | Description |
|---|------|------|-------------|
| 1 | NEW | `devlog/210_pr8-autostart-ensure-review/00_plan.md` | 이 파일 |
| 2 | NEW | `devlog/210_pr8-autostart-ensure-review/10_review.md` | 리파인된 리뷰 결과 |
| 3 | — | PR #8 comment | gh pr review로 코멘트 작성 |

## Steps

1. 앞서 수행한 리뷰 결과를 검증 및 리파인하여 `10_review.md` 작성
2. dev 브랜치에 커밋 + 푸시
3. `gh pr comment 8`으로 리뷰 코멘트 게시
99 changes: 99 additions & 0 deletions devlog/210_pr8-autostart-ensure-review/10_review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# PR #8 Code Review — Autostart Ensure Fallback

> PR: https://github.com/lidge-jun/opencodex/pull/8
> Author: 이완우 (Ingwannu)
> Reviewed: 2026-06-21
> Status: Draft / +450 -53 / 19 files

## 변경 요약

shim 스크립트의 인라인 bash 로직(30줄+)을 `ocx ensure` 단일 명령어로 통합.
포트 충돌 시 자동 fallback, `codexAutoStart` 대시보드 토글, `ocx uninstall` 일괄 정리 추가.

## 수정 필요 (Must Fix)

### 1. server.ts — 불필요한 dynamic import

`src/server.ts`의 `/api/settings` PUT 핸들러에서 `saveConfig`를 dynamic import로 다시 불러오고 있으나,
파일 상단에 이미 static import 되어 있음.

```typescript
// PR 현재 (line ~654):
const { saveConfig: save } = await import("./config");
save(config);

// 수정안:
saveConfig(config);
```

`saveConfig`는 같은 파일 line 16에서 `import { ..., saveConfig, ... } from "./config"`로 이미 가져옴.
dynamic import는 불필요한 런타임 비용 + 코드 혼동을 유발함.

### 2. handleEnsure — sync 실패 무음 처리

`src/cli.ts`의 `handleEnsure()` 내 두 곳에서 `syncModelsToCodex().catch(() => {})`로
에러를 완전히 삼키고 있음.

```typescript
// line ~388:
await syncModelsToCodex(config.port).catch(() => {});

// line ~406:
await syncModelsToCodex(config.port ?? port).catch(() => {});
```

`ensure`는 "프록시가 준비되었음을 보장"하는 명령어인데, 모델 sync가 실패하면
Codex가 stale 캐시로 동작할 수 있음. 최소한 stderr 경고를 찍어야 디버깅 가능:

```typescript
await syncModelsToCodex(config.port).catch(e => {
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
});
```

## 개선 권장 (Should Fix)

### 3. Dashboard 토글 실패 시 에러 미표시

`gui/src/pages/Dashboard.tsx`의 `toggleCodexAutoStart` catch 블록에서
토글을 조용히 원복만 하고 사용자에게 에러를 알리지 않음.

```typescript
} catch {
setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev);
// ← 에러 toast나 상태 표시 없음
}
```

네트워크 오류나 서버 다운 시 사용자는 토글이 왜 안 먹히는지 알 수 없음.

### 4. uninstall/startup-prompt 테스트가 소스 문자열 매칭

`tests/uninstall.test.ts`와 `tests/startup-prompt.test.ts`의 일부 테스트가
소스 파일을 텍스트로 읽어서 함수명/문자열 포함 여부만 확인:

```typescript
expect(cli).toContain("async function handleUninstall()");
expect(cli).toContain("uninstallServiceIfInstalled");
```

리팩토링(함수명 변경, 추출)하면 기능은 그대로인데 테스트가 깨짐.
`ports.test.ts`처럼 실제 동작을 테스트하는 방식이 더 견고함.

## 참고 (Note)

### 5. 포트 선점 경쟁 조건 (TOCTOU)

`src/ports.ts`의 `findAvailablePort`가 포트를 확인한 뒤 서버를 닫고 포트 번호를 반환.
`startServer()`가 실제로 bind하기 전에 다른 프로세스가 끼어들 수 있음.
이는 포트 검사의 본질적 한계이므로 blocking은 아니나,
`startServer` 내부에서 `EADDRINUSE` 발생 시 재시도 로직이 있으면 더 안전함.

## 긍정 포인트

- shim 스크립트 간소화가 핵심 성과 — bash/batch 30줄 → `ensure` 1줄
- 죽은 PID 감지 후 자동 정리 (기존: 에러 내고 종료)
- `waitForProxy`가 매 루프마다 config를 다시 읽어서 자식 프로세스의 포트 변경 반영
- `uninstallServiceIfInstalled` — 서비스 없어도 process.exit 안 하는 quiet helper
- i18n 3개 언어(en/ko/zh) 모두 업데이트
- `codexAutoStart` 기본값 `!== false`로 안전한 opt-out 패턴
27 changes: 27 additions & 0 deletions devlog/210_pr8-autostart-ensure-review/20_apply-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 210-20 — PR #8 코드 적용 + 리뷰 수정 계획

## 전략

1. PR #8의 2개 커밋을 dev에 squash cherry-pick (기여자 Co-Authored-By 유지)
2. 리뷰 지적 4건 수정을 별도 커밋으로 적용

## 수정 사항

### Fix 1: server.ts — 불필요한 dynamic import 제거
- `/api/settings` PUT 핸들러에서 `await import("./config")` 제거
- 이미 static import된 `saveConfig` 직접 사용

### Fix 2: cli.ts — handleEnsure sync 실패 로깅
- `.catch(() => {})` → `.catch(e => console.error(...))`
- 2곳 모두 적용

### Fix 3: Dashboard.tsx — 토글 실패 시 에러 표시
- catch 블록에 `setError(true)` 또는 console.error 추가

### Fix 4: (optional) uninstall.test.ts — 문자열 매칭 테스트 개선 메모
- 현재 단계에서는 메모만 (기능 테스트로 전환은 후속 작업)

## 커밋 계획

1. `feat: squash PR #8 — Codex autostart ensure fallback` (Co-Authored-By: 이완우)
2. `fix: address PR #8 review — dynamic import, sync logging, toggle error`
7 changes: 7 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export const en = {
"dash.stopping": "Stopping…",
"dash.codexAutoStart": "Start opencodex with Codex",
"dash.codexAutoStartHint": "When enabled, the Codex shim runs ocx ensure before every Codex CLI/App launch. Disable this to leave Codex untouched.",
"dash.searchModel": "Search sidecar model",
"dash.searchModelHint": "Model used for web_search on non-OpenAI routed models. Requires ChatGPT login.",
"dash.searchReasoning": "Search reasoning effort",
"dash.visionModel": "Vision sidecar model",
"dash.visionModelHint": "Model used to describe images for text-only routed models. Requires ChatGPT login.",
"dash.sidecarSaved": "Sidecar settings saved. Applied on the next request.",
"dash.sidecarSaveFailed": "Failed to save sidecar settings.",

// providers
"prov.subtitle": "Configure the upstream providers opencodex routes into Codex. Log in with an account, add a provider, or edit the raw config.",
Expand Down
7 changes: 7 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export const ko: Record<TKey, string> = {
"dash.stopping": "중지 중…",
"dash.codexAutoStart": "Codex 실행 시 opencodex 시작",
"dash.codexAutoStartHint": "켜져 있으면 Codex CLI/App 실행 전 shim이 ocx ensure를 실행합니다. 끄면 Codex를 건드리지 않습니다.",
"dash.searchModel": "서치 사이드카 모델",
"dash.searchModelHint": "비-OpenAI 라우팅 모델의 web_search에 사용되는 모델입니다. ChatGPT 로그인 필요.",
"dash.searchReasoning": "서치 추론 강도",
"dash.visionModel": "비전 사이드카 모델",
"dash.visionModelHint": "텍스트 전용 라우팅 모델에 이미지를 설명하는 데 사용되는 모델입니다. ChatGPT 로그인 필요.",
"dash.sidecarSaved": "사이드카 설정이 저장됐습니다. 다음 요청부터 적용됩니다.",
"dash.sidecarSaveFailed": "사이드카 설정 저장에 실패했습니다.",

// providers
"prov.subtitle": "opencodex가 Codex로 라우팅하는 업스트림 프로바이더를 설정합니다. 계정으로 로그인하거나, 프로바이더를 추가하거나, 원본 설정을 편집하세요.",
Expand Down
7 changes: 7 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export const zh: Record<TKey, string> = {
"dash.stopping": "正在停止…",
"dash.codexAutoStart": "随 Codex 启动 opencodex",
"dash.codexAutoStartHint": "启用后,Codex shim 会在每次 Codex CLI/App 启动前运行 ocx ensure。关闭后不会改动 Codex。",
"dash.searchModel": "搜索附属模型",
"dash.searchModelHint": "用于非 OpenAI 路由模型的 web_search 的模型。需要 ChatGPT 登录。",
"dash.searchReasoning": "搜索推理强度",
"dash.visionModel": "视觉附属模型",
"dash.visionModelHint": "为纯文本路由模型描述图像的模型。需要 ChatGPT 登录。",
"dash.sidecarSaved": "附属设置已保存。将在下一个请求时生效。",
"dash.sidecarSaveFailed": "保存附属设置失败。",

// providers
"prov.subtitle": "配置 opencodex 路由到 Codex 的上游提供方。使用账户登录、添加提供方,或编辑原始配置。",
Expand Down
67 changes: 66 additions & 1 deletion gui/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,36 @@ interface HealthData { status: string; version: string; uptime: number }
interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean }
interface ModelInfo { id: string; provider: string; owned_by?: string }
interface SettingsData { codexAutoStart: boolean; port: number; hostname: string }
interface SidecarData { webSearch: { model: string; reasoning: string }; vision: { model: string } }

const SIDECAR_MODELS = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.5", "gpt-5.3-codex-spark"];
const REASONING_LEVELS = ["low", "medium", "high"];

export default function Dashboard({ apiBase }: { apiBase: string }) {
const t = useT();
const [health, setHealth] = useState<HealthData | null>(null);
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [models, setModels] = useState<ModelInfo[]>([]);
const [settings, setSettings] = useState<SettingsData | null>(null);
const [sidecar, setSidecar] = useState<SidecarData | null>(null);
const [sidecarSaving, setSidecarSaving] = useState(false);
const [modelsLoading, setModelsLoading] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [error, setError] = useState(false);

useEffect(() => {
const fetchData = async () => {
try {
const [hRes, pRes, sRes] = await Promise.all([
const [hRes, pRes, sRes, scRes] = await Promise.all([
fetch(`${apiBase}/healthz`),
fetch(`${apiBase}/api/providers`),
fetch(`${apiBase}/api/settings`),
fetch(`${apiBase}/api/sidecar-settings`),
]);
setHealth(await hRes.json());
setProviders(await pRes.json());
setSettings(await sRes.json());
setSidecar(await scRes.json());
setError(false);
} catch {
setError(true);
Expand Down Expand Up @@ -66,6 +74,30 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {

const online = health?.status === "ok";

const saveSidecar = async (patch: Partial<SidecarData>) => {
if (!sidecar || sidecarSaving) return;
const next = {
webSearch: { ...sidecar.webSearch, ...patch.webSearch },
vision: { ...sidecar.vision, ...patch.vision },
};
setSidecarSaving(true);
setSidecar(next);
try {
const res = await fetch(`${apiBase}/api/sidecar-settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
if (!res.ok) throw new Error("save failed");
const data = await res.json();
setSidecar({ webSearch: data.webSearch, vision: data.vision });
} catch {
setSidecar(sidecar);
} finally {
setSidecarSaving(false);
}
};

const toggleCodexAutoStart = async () => {
if (!settings || settingsSaving) return;
const next = !settings.codexAutoStart;
Expand All @@ -82,6 +114,7 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart } : prev);
} catch {
setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev);
setError(true);
} finally {
setSettingsSaving(false);
}
Expand Down Expand Up @@ -122,6 +155,38 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
</div>
</div>

<div className="panel" style={{ marginBottom: 12 }}>
<div className="spread" style={{ alignItems: "flex-start" }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 650 }}>{t("dash.searchModel")}</div>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{t("dash.searchModelHint")}</div>
</div>
<div style={{ display: "flex", gap: 8 }}>
<select className="select-sm" value={sidecar?.webSearch.model ?? "gpt-5.4-mini"} disabled={!sidecar || sidecarSaving}
onChange={e => saveSidecar({ webSearch: { model: e.target.value, reasoning: sidecar!.webSearch.reasoning } })}>
{SIDECAR_MODELS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<select className="select-sm" value={sidecar?.webSearch.reasoning ?? "low"} disabled={!sidecar || sidecarSaving}
onChange={e => saveSidecar({ webSearch: { model: sidecar!.webSearch.model, reasoning: e.target.value } })}>
{REASONING_LEVELS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
</div>

<div className="panel" style={{ marginBottom: 24 }}>
<div className="spread">
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 650 }}>{t("dash.visionModel")}</div>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{t("dash.visionModelHint")}</div>
</div>
<select className="select-sm" value={sidecar?.vision.model ?? "gpt-5.4-mini"} disabled={!sidecar || sidecarSaving}
onChange={e => saveSidecar({ vision: { model: e.target.value } })}>
{SIDECAR_MODELS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>

<div className="h-section">{t("dash.activeProviders")} <span className="count">{providers.length}</span></div>
{providers.length === 0 ? (
<div className="empty"><Trans k="dash.noProviders" cmd="ocx init" /></div>
Expand Down
6 changes: 6 additions & 0 deletions gui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
textarea.input { resize: vertical; font-family: var(--mono); line-height: 1.55; }
.field-label { font-size: 12px; color: var(--muted); font-weight: 500; margin-bottom: 5px; display: block; }
select.input { appearance: none; }
.select-sm {
padding: 5px 8px; border-radius: var(--radius-sm); background: var(--raised); border: 1px solid var(--border);
color: var(--text); font: inherit; font-size: 13px; cursor: pointer; transition: border-color 0.12s;
}
.select-sm:focus { border-color: var(--accent); outline: none; }
.select-sm:disabled { opacity: 0.5; cursor: default; }

/* ---- switch ---- */
.switch { width: 34px; height: 19px; border-radius: 99px; border: none; cursor: pointer; background: light-dark(#c5c9d2, #3a3f4b); position: relative; flex-shrink: 0; transition: background 0.15s; padding: 0; }
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
stream: parsed.stream,
};
if (tools) body.tools = tools;
if (toolChoice !== undefined) {
if (tools && toolChoice !== undefined) {
body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)
? (toolChoice === "none" ? "none" : "auto")
: toolChoice;
Expand Down
8 changes: 6 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ async function handleEnsure() {
return;
}
if (await proxyHealthy(config.port)) {
await syncModelsToCodex(config.port).catch(() => {});
await syncModelsToCodex(config.port).catch(e => {
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
});
console.log(`✅ Proxy running on port ${config.port}`);
return;
}
Expand All @@ -177,7 +179,9 @@ async function handleEnsure() {
process.exit(1);
}
config = loadConfig();
await syncModelsToCodex(config.port ?? port).catch(() => {});
await syncModelsToCodex(config.port ?? port).catch(e => {
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
});
console.log(`✅ Proxy running on port ${config.port ?? port}`);
}

Expand Down
Loading
Loading