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
12 changes: 12 additions & 0 deletions .changeset/codex-cli-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@planningo/duul": minor
---

Support Codex CLI login for the `openai` provider — no `OPENAI_API_KEY` required.

When no `OPENAI_API_KEY` (or per-request `api_key`) is set, DUUL now falls back to the OpenAI Codex CLI credentials in `~/.codex/auth.json` (override with `CODEX_HOME`):

- **Sign in with ChatGPT:** uses the OAuth access token against the ChatGPT backend Responses endpoint (`https://chatgpt.com/backend-api/codex`), billed to your ChatGPT plan. Tokens are refreshed automatically via the OAuth endpoint (on expiry and on a mid-review 401).
- **API-key login:** uses the `OPENAI_API_KEY` stored in `auth.json`.

The ChatGPT backend is stateless (`store: false`): DUUL streams the request, aggregates output items from the stream, resends the full input (echoing encrypted reasoning) across tool rounds, and drops unsupported params (`temperature`, `top_p`, `max_output_tokens`, `previous_response_id`). Cross-round context is preserved by replaying prior rounds' turns (new `conversationReplay` provider capability, same mechanism as the Anthropic provider), so `previous_review_id` continuity works. Add `DUUL_REASONING_EFFORT` (default `medium`) to tune reasoning effort. An explicit env/request key always takes precedence over the CLI login.
27 changes: 26 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,42 @@ npm run build
|------|------|--------|------|
| `REVIEW_PROVIDER` | 아니오 | `openai` | 프로바이더: `openai`, `anthropic`, `google`, `openrouter`, `compatible` |
| `REVIEW_MODEL` | 아니오 | 프로바이더 기본값 | 모델 ID (예: `gpt-5.4`, `claude-opus-4-20250514`, `gemini-3.1-pro-preview`) |
| `OPENAI_API_KEY` | 조건부 | -- | `openai` 또는 `compatible` 프로바이더 사용 시 필수 |
| `OPENAI_API_KEY` | 조건부 | -- | `openai`/`compatible`용 API 키. Codex CLI 로그인 시 생략 가능 (아래 참고) |
| `ANTHROPIC_API_KEY` | 조건부 | -- | `anthropic` 프로바이더 사용 시 필수 |
| `GOOGLE_API_KEY` | 조건부 | -- | `google` 프로바이더 사용 시 필수 |
| `OPENROUTER_API_KEY` | 조건부 | -- | `openrouter` 프로바이더 사용 시 필수 |
| `REVIEW_API_KEY` | 아니오 | -- | `compatible` 프로바이더용 API 키 (`OPENAI_API_KEY`로 폴백) |
| `CODEX_HOME` | 아니오 | `~/.codex` | Codex CLI `auth.json` 위치 (CLI 로그인용) |
| `DUUL_REASONING_EFFORT` | 아니오 | `medium` | ChatGPT 로그인 시 추론 강도 (`minimal`\|`low`\|`medium`\|`high`) |

프로바이더별 기본 모델:
- **OpenAI:** `gpt-5.4`
- **Anthropic:** `claude-opus-4-20250514`
- **Google:** `gemini-3.1-pro-preview`

#### Codex CLI 로그인 사용 (API 키 불필요)

`openai` 프로바이더는 [OpenAI Codex CLI](https://developers.openai.com/codex)에
이미 로그인되어 있으면 `OPENAI_API_KEY` 없이도 동작합니다:

```bash
codex login # "Sign in with ChatGPT" (Plus/Pro/Team) 또는 API 키 입력
```

DUUL은 `~/.codex/auth.json`을 읽고(`CODEX_HOME`으로 경로 변경 가능):

- **Sign in with ChatGPT:** OAuth 토큰으로 ChatGPT 백엔드
(`https://chatgpt.com/backend-api/codex`)를 호출합니다. 토큰당 과금이 아니라
ChatGPT 요금제로 청구되며, 만료된 토큰은 자동 갱신됩니다.
- **API 키 로그인:** `auth.json`에 저장된 `OPENAI_API_KEY`를 사용합니다.

우선순위: 명시적 `OPENAI_API_KEY` 환경변수(또는 요청별 `api_key`)가 항상 우선이며,
키가 없을 때만 Codex 로그인으로 폴백합니다. 모델은 ChatGPT 요금제가 제공하는 것으로
제한됩니다(예: `gpt-5.4`, `gpt-5.5`) — `REVIEW_MODEL`로 선택하세요. ChatGPT
백엔드는 무상태(stateless)라 네이티브 `previous_response_id` 체이닝 대신, 이전
라운드의 대화 턴을 재생(replay)해 라운드 간 컨텍스트를 유지합니다(Anthropic
프로바이더와 동일한 방식) — `previous_review_id` 연속성이 정상 동작합니다.

#### 반복 제한

각 단계에는 최대 리뷰 반복 횟수가 있습니다. 초과하면 서버가 `requires_human_review: true`를 반환하여 사람에게 에스컬레이션합니다.
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,43 @@ All configuration is done via environment variables, passed through the MCP `env
|----------|----------|---------|-------------|
| `REVIEW_PROVIDER` | No | `openai` | Provider: `openai`, `anthropic`, `google`, `openrouter`, `compatible` |
| `REVIEW_MODEL` | No | Provider default | Model ID (e.g. `gpt-5.4`, `claude-opus-4-20250514`, `gemini-3.1-pro-preview`) |
| `OPENAI_API_KEY` | Conditional | -- | Required for `openai` or `compatible` provider |
| `OPENAI_API_KEY` | Conditional | -- | API key for `openai`/`compatible`. Optional if signed in with the Codex CLI (see below) |
| `ANTHROPIC_API_KEY` | Conditional | -- | Required for `anthropic` provider |
| `GOOGLE_API_KEY` | Conditional | -- | Required for `google` provider |
| `OPENROUTER_API_KEY` | Conditional | -- | Required for `openrouter` provider |
| `REVIEW_API_KEY` | No | -- | API key for `compatible` provider (falls back to `OPENAI_API_KEY`) |
| `CODEX_HOME` | No | `~/.codex` | Directory holding the Codex CLI `auth.json` (for CLI login) |
| `DUUL_REASONING_EFFORT` | No | `medium` | Reasoning effort for Sign in with ChatGPT (`minimal`\|`low`\|`medium`\|`high`) |

Default models per provider:
- **OpenAI:** `gpt-5.4`
- **Anthropic:** `claude-opus-4-20250514`
- **Google:** `gemini-3.1-pro-preview`

#### Sign in with the Codex CLI (no API key)

For the `openai` provider you don't need an `OPENAI_API_KEY` if you're already
logged in to the [OpenAI Codex CLI](https://developers.openai.com/codex):

```bash
codex login # "Sign in with ChatGPT" (Plus/Pro/Team) — or paste an API key
```

DUUL reads `~/.codex/auth.json` (override with `CODEX_HOME`) and:

- **Sign in with ChatGPT:** uses your OAuth token against the ChatGPT backend
(`https://chatgpt.com/backend-api/codex`). Requests are billed to your ChatGPT
plan, not per-token. Expired tokens are refreshed automatically.
- **API-key login:** uses the `OPENAI_API_KEY` stored in `auth.json`.

Precedence: an explicit `OPENAI_API_KEY` env var (or per-request `api_key`) always
wins; the Codex login is only used as a fallback when no key is set. Models are
limited to those your ChatGPT plan exposes (e.g. `gpt-5.4`, `gpt-5.5`); set
`REVIEW_MODEL` to pick one. The ChatGPT backend is stateless, so instead of
native `previous_response_id` chaining DUUL preserves cross-round context by
replaying prior rounds' turns (the same mechanism the Anthropic provider uses) —
`previous_review_id` continuity works as usual.

#### Iteration Limits

Each phase has a maximum number of review iterations. When exceeded, the server returns `requires_human_review: true` so the caller can escalate to a human.
Expand Down Expand Up @@ -484,7 +510,7 @@ When `workspace_root` is provided, the reviewer gains access to 7 file explorati
**Degradation behavior:**
- **No structured outputs:** JSON prompting + zod validation fallback.
- **No tool calling:** Reviewer cannot explore the workspace. Provide more context via `relevant_code` and `artifact_refs`.
- **No previous response ID:** Each review call is independent (no conversation memory).
- **No previous response ID:** Native server-side chaining is unavailable. Anthropic and the OpenAI ChatGPT-login backend still preserve cross-round context by replaying prior turns (conversation replay); Google is independent per call.

---

Expand Down
189 changes: 189 additions & 0 deletions src/__tests__/codex-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync, statSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { jwtExp, isTokenExpired, loadCodexAuth, resolveCodexCredential, refreshCodexToken, codexHome } from '../services/providers/codex-auth.js';

/** Build an unsigned JWT (header.payload.sig) with the given payload. */
function makeJwt(payload: Record<string, unknown>): string {
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`;
}

function withCodexHome<T>(auth: unknown | null, fn: () => T): T {
const dir = mkdtempSync(join(tmpdir(), 'duul-codex-'));
const prev = process.env.CODEX_HOME;
process.env.CODEX_HOME = dir;
try {
if (auth !== null) writeFileSync(join(dir, 'auth.json'), JSON.stringify(auth));
return fn();
} finally {
if (prev === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prev;
rmSync(dir, { recursive: true, force: true });
}
}

test('codexHome honors CODEX_HOME override', () => {
const prev = process.env.CODEX_HOME;
process.env.CODEX_HOME = '/tmp/fake-codex';
try {
assert.equal(codexHome(), '/tmp/fake-codex');
} finally {
if (prev === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prev;
}
});

test('jwtExp decodes the exp claim', () => {
assert.equal(jwtExp(makeJwt({ exp: 123456 })), 123456);
});

test('jwtExp returns null for non-JWT strings', () => {
assert.equal(jwtExp('sk-not-a-jwt'), null);
assert.equal(jwtExp('a.b'), null); // payload not valid base64 JSON
});

test('isTokenExpired: future token is not expired', () => {
const future = Math.floor(Date.now() / 1000) + 3600;
assert.equal(isTokenExpired(makeJwt({ exp: future })), false);
});

test('isTokenExpired: token within skew window is expired', () => {
const soon = Math.floor(Date.now() / 1000) + 60; // < 5min skew
assert.equal(isTokenExpired(makeJwt({ exp: soon })), true);
});

test('isTokenExpired: unknown expiry treated as not expired', () => {
assert.equal(isTokenExpired('opaque-token'), false);
});

test('loadCodexAuth returns null when file absent', () => {
withCodexHome(null, () => {
assert.equal(loadCodexAuth(), null);
});
});

test('loadCodexAuth parses auth.json', () => {
withCodexHome({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-test' }, () => {
assert.deepEqual(loadCodexAuth(), { auth_mode: 'apikey', OPENAI_API_KEY: 'sk-test' });
});
});

test('resolveCodexCredential: apikey mode', async () => {
const cred = await withCodexHome(
{ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-live-abc' },
() => resolveCodexCredential(),
);
assert.deepEqual(cred, { mode: 'apikey', apiKey: 'sk-live-abc' });
});

test('resolveCodexCredential: chatgpt mode with valid token needs no network', async () => {
const future = Math.floor(Date.now() / 1000) + 3600;
const cred = await withCodexHome(
{
auth_mode: 'chatgpt',
OPENAI_API_KEY: null,
tokens: { access_token: makeJwt({ exp: future }), refresh_token: 'r', account_id: 'acct-1' },
},
() => resolveCodexCredential(),
);
assert.equal(cred?.mode, 'chatgpt');
if (cred?.mode === 'chatgpt') {
assert.equal(cred.accountId, 'acct-1');
assert.ok(cred.accessToken.length > 0);
}
});

test('resolveCodexCredential: returns null when logged out', async () => {
const cred = await withCodexHome(null, () => resolveCodexCredential());
assert.equal(cred, null);
});

test('resolveCodexCredential: expired token + no refresh_token falls back to api key', async () => {
const past = Math.floor(Date.now() / 1000) - 3600;
const cred = await withCodexHome(
{
auth_mode: 'chatgpt',
OPENAI_API_KEY: 'sk-fallback',
tokens: { access_token: makeJwt({ exp: past }), account_id: 'acct-3' },
},
() => resolveCodexCredential(),
);
assert.deepEqual(cred, { mode: 'apikey', apiKey: 'sk-fallback' });
});

test('resolveCodexCredential: expired token, no refresh, no key returns chatgpt cred', async () => {
const past = Math.floor(Date.now() / 1000) - 3600;
const cred = await withCodexHome(
{
auth_mode: 'chatgpt',
OPENAI_API_KEY: null,
tokens: { access_token: makeJwt({ exp: past }), account_id: 'acct-4' },
},
() => resolveCodexCredential(),
);
// No refresh path and no key: proceed so the provider surfaces a clear error.
assert.equal(cred?.mode, 'chatgpt');
});

test('refreshCodexToken forces 0600 on a pre-existing loose file + persists rotated tokens', async () => {
const dir = mkdtempSync(join(tmpdir(), 'duul-codex-'));
const prevHome = process.env.CODEX_HOME;
const prevFetch = globalThis.fetch;
process.env.CODEX_HOME = dir;
const path = join(dir, 'auth.json');
const auth = { auth_mode: 'chatgpt', tokens: { access_token: 'old', refresh_token: 'r1', account_id: 'a' } };
writeFileSync(path, JSON.stringify(auth));
chmodSync(path, 0o644); // simulate a loosely-permissioned file

globalThis.fetch = (async () =>
new Response(JSON.stringify({ access_token: 'new-at', id_token: 'new-id', refresh_token: 'r2' }), {
status: 200,
})) as typeof fetch;

try {
const updated = await refreshCodexToken(auth);
assert.equal(updated.tokens?.access_token, 'new-at');
assert.equal(updated.tokens?.refresh_token, 'r2');
assert.equal(statSync(path).mode & 0o777, 0o600);
// Rotated tokens are persisted to disk.
assert.equal(loadCodexAuth()?.tokens?.access_token, 'new-at');
} finally {
globalThis.fetch = prevFetch;
if (prevHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevHome;
rmSync(dir, { recursive: true, force: true });
}
});

test('refreshCodexToken throws on HTTP failure', async () => {
const prevFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response('nope', { status: 400 })) as typeof fetch;
try {
await assert.rejects(
() => refreshCodexToken({ tokens: { refresh_token: 'r' } }),
/token refresh failed/,
);
} finally {
globalThis.fetch = prevFetch;
}
});

test('refreshCodexToken throws when no refresh_token', async () => {
await assert.rejects(() => refreshCodexToken({ tokens: {} }), /no refresh_token/);
});

test('resolveCodexCredential: chatgpt preferred even when api key present', async () => {
const future = Math.floor(Date.now() / 1000) + 3600;
const cred = await withCodexHome(
{
auth_mode: 'chatgpt',
OPENAI_API_KEY: 'sk-should-be-ignored',
tokens: { access_token: makeJwt({ exp: future }), refresh_token: 'r', account_id: 'acct-2' },
},
() => resolveCodexCredential(),
);
assert.equal(cred?.mode, 'chatgpt');
});
32 changes: 32 additions & 0 deletions src/__tests__/openai-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { OpenAIProvider } from '../services/providers/openai.js';

// Constructors do not hit the network, so these assert the capability contract
// without any API calls.

test('openai api-key mode: native chaining, no replay', () => {
const p = new OpenAIProvider({ apiKey: 'sk-test' });
assert.equal(p.capabilities.previousResponseId, true);
assert.equal(p.capabilities.conversationReplay, false);
assert.equal(p.capabilities.toolCalling, true);
assert.equal(p.capabilities.structuredOutputs, true);
});

test('openai ChatGPT-login mode: replay continuity, no native chaining', () => {
const p = new OpenAIProvider({ chatgpt: { accessToken: 'tok', accountId: 'acct' } });
assert.equal(p.capabilities.previousResponseId, false);
assert.equal(p.capabilities.conversationReplay, true);
assert.equal(p.capabilities.toolCalling, true);
assert.equal(p.capabilities.structuredOutputs, true);
});

test('openai constructor without any credential throws', () => {
const prev = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
assert.throws(() => new OpenAIProvider({}), /No OpenAI credential/);
} finally {
if (prev !== undefined) process.env.OPENAI_API_KEY = prev;
}
});
Loading
Loading