Skip to content

Commit 2113a94

Browse files
committed
Use direct Codex OAuth provider
1 parent b88e8e9 commit 2113a94

12 files changed

Lines changed: 367 additions & 337 deletions

File tree

packages/core/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ EMBEDDING_DIMENSIONS=1536
110110

111111
# Personal local Codex extraction, no separate OpenAI API key:
112112
# LLM_PROVIDER=codex
113+
# Optional: defaults to CODEX_HOME/auth.json or ~/.codex/auth.json.
114+
# CODEX_AUTH_PATH=/path/to/codex/auth.json
113115
# For fully local/no-provider-key development, pair this with a non-OpenAI
114116
# embedding provider such as EMBEDDING_PROVIDER=transformers.
115117

packages/core/Dockerfile

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,6 @@ RUN pnpm deploy --filter @atomicmemory/core --prod /deploy
4545
# ---------------------------------------------------------------------------
4646
FROM pgvector/pgvector:pg17
4747

48-
ARG CODEX_CLI_VERSION=0.131.0
49-
5048
WORKDIR /app
5149

5250
COPY --from=node-base /usr/local/bin/node /usr/local/bin/node
@@ -55,14 +53,9 @@ RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
5553
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
5654

5755
RUN apt-get update && apt-get install -y --no-install-recommends \
58-
ca-certificates bubblewrap \
56+
ca-certificates \
5957
&& rm -rf /var/lib/apt/lists/*
6058

61-
# Optional local-account LLM provider support. `LLM_PROVIDER=codex` delegates
62-
# extraction turns to this CLI when a Codex auth home is mounted at runtime.
63-
RUN npm install -g --no-audit --no-fund "@openai/codex@${CODEX_CLI_VERSION}" \
64-
&& codex --version | grep -q "${CODEX_CLI_VERSION}"
65-
6659
# Production node_modules + package.json from pnpm deploy.
6760
COPY --from=builder /deploy/node_modules ./node_modules
6861
COPY --from=builder /deploy/package.json ./package.json

packages/core/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,11 +297,13 @@ Set `LLM_PROVIDER` to choose the extraction backend:
297297
| `anthropic` | Anthropic Messages API |
298298
| `google-genai` | Google Gemini OpenAI-compatible endpoint |
299299
| `claude-code` | Local Claude Code Agent SDK session for personal development |
300-
| `codex` | Local Codex CLI account session for personal development |
300+
| `codex` | Local Codex account session for personal development |
301301

302302
For personal local use, `LLM_PROVIDER=claude-code` and `LLM_PROVIDER=codex`
303-
use the logged-in `claude` or `codex` CLI session instead of requiring a
304-
separate LLM API key. They still consume the user's account limits and are not
303+
use the logged-in `claude` or `codex` account session instead of requiring a
304+
separate LLM API key. `claude-code` routes through the Claude Agent SDK;
305+
`codex` reads the auth file produced by `codex login` and calls the Codex
306+
backend directly. They still consume the user's account limits and are not
305307
intended for hosted or team deployments. Pair either one with a non-OpenAI
306308
embedding provider, such as `EMBEDDING_PROVIDER=transformers`, if you want to
307309
run without an OpenAI API key as well.

packages/core/src/__tests__/config-env.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const trackedEnvNames = [
1818
'RAW_STORAGE_DEPLOYMENT_ENV',
1919
'OPENAI_API_KEY',
2020
'ANTHROPIC_API_KEY',
21+
'CODEX_AUTH_PATH',
22+
'CODEX_HOME',
2123
] as const;
2224
const originalEnv = Object.fromEntries(
2325
trackedEnvNames.map((name) => [name, process.env[name]]),
@@ -99,7 +101,8 @@ describe('config env loading', () => {
99101
const { config } = await import('../config.js');
100102

101103
expect(config.llmProvider).toBe('codex');
102-
expect(config.llmModel).toBe('');
104+
expect(config.llmModel).toBe('gpt-5.4-mini');
105+
expect(config.codexAuthPath).toContain('.codex/auth.json');
103106
});
104107

105108
it('loads optional admin cleanup endpoint config', async () => {

packages/core/src/config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
type RetrievalProfile,
1111
type RetrievalProfileName,
1212
} from './services/retrieval-profiles.js';
13+
import { homedir } from 'node:os';
14+
import { join } from 'node:path';
1315
import { parsePointerUriSchemes } from './storage/pointer-uri-allowlist.js';
1416
import {
1517
collectFilecoinProviderEnvKeys,
@@ -123,6 +125,7 @@ export interface RuntimeConfig {
123125
llmModel: string;
124126
llmApiUrl?: string;
125127
llmApiKey?: string;
128+
codexAuthPath: string;
126129
groqApiKey?: string;
127130
ollamaBaseUrl: string;
128131
vectorBackend: VectorBackendName;
@@ -696,10 +699,19 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName):
696699
}
697700

698701
function defaultLlmModel(provider: LLMProviderName): string {
699-
if (provider === 'claude-code' || provider === 'codex') return '';
702+
if (provider === 'claude-code') return '';
703+
if (provider === 'codex') return 'gpt-5.4-mini';
700704
return 'gpt-4o-mini';
701705
}
702706

707+
function defaultCodexAuthPath(): string {
708+
const explicitPath = optionalEnv('CODEX_AUTH_PATH');
709+
if (explicitPath) return explicitPath;
710+
const codexHome = optionalEnv('CODEX_HOME');
711+
if (codexHome) return join(codexHome, 'auth.json');
712+
return join(homedir(), '.codex', 'auth.json');
713+
}
714+
703715

704716
function requireFiniteNumber(value: number, field: string): number {
705717
if (!Number.isFinite(value)) {
@@ -1164,6 +1176,7 @@ export const config: RuntimeConfig = {
11641176
llmModel: optionalEnv('LLM_MODEL') ?? defaultLlmModel(llmProvider),
11651177
llmApiUrl: optionalEnv('LLM_API_URL'),
11661178
llmApiKey: optionalEnv('LLM_API_KEY'),
1179+
codexAuthPath: defaultCodexAuthPath(),
11671180

11681181
// Groq
11691182
groqApiKey: groqApiKey ?? undefined,

packages/core/src/services/__tests__/codex-cli-llm.test.ts

Lines changed: 0 additions & 138 deletions
This file was deleted.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Unit tests for the Codex OAuth-backed LLM provider.
3+
*/
4+
5+
import { readFile } from 'node:fs/promises';
6+
import { afterEach, describe, expect, it, vi } from 'vitest';
7+
8+
vi.mock('node:fs/promises', () => ({
9+
readFile: vi.fn(),
10+
}));
11+
12+
const { CodexLLM } = await import('../codex-llm.js');
13+
const readFileMock = vi.mocked(readFile);
14+
const fetchMock = vi.fn<typeof fetch>();
15+
16+
afterEach(() => {
17+
vi.clearAllMocks();
18+
vi.unstubAllGlobals();
19+
});
20+
21+
function provider(): InstanceType<typeof CodexLLM> {
22+
return new CodexLLM({
23+
llmProvider: 'codex',
24+
llmModel: 'gpt-5.4-mini',
25+
llmApiUrl: undefined,
26+
codexAuthPath: '/tmp/codex-auth.json',
27+
costLoggingEnabled: false,
28+
costRunId: 'test',
29+
costLogDir: '/tmp/test-cost',
30+
});
31+
}
32+
33+
function mockAuth(): void {
34+
readFileMock.mockResolvedValue(JSON.stringify({
35+
auth_mode: 'chatgpt',
36+
tokens: {
37+
access_token: 'codex-token',
38+
account_id: 'acct-123',
39+
},
40+
}));
41+
}
42+
43+
function mockSseResponse(body: string): void {
44+
vi.stubGlobal('fetch', fetchMock);
45+
fetchMock.mockResolvedValue({
46+
ok: true,
47+
status: 200,
48+
text: async () => body,
49+
} as Response);
50+
}
51+
52+
function sse(...parts: string[]): string {
53+
return parts.map((part) => `event: response.text.delta\ndata: ${JSON.stringify({ delta: part })}\n`).join('');
54+
}
55+
56+
describe('CodexLLM', () => {
57+
it('calls the Codex backend directly with OAuth credentials from the Codex auth file', async () => {
58+
mockAuth();
59+
mockSseResponse(sse('{', '"memories": []', '}'));
60+
61+
const text = await provider().chat([
62+
{ role: 'system', content: 'Extract memory JSON.' },
63+
{ role: 'user', content: 'User prefers concise answers.' },
64+
], { jsonMode: true, maxTokens: 256 });
65+
66+
const fetchCall = fetchMock.mock.calls[0];
67+
const requestBody = JSON.parse(String(fetchCall?.[1]?.body)) as Record<string, unknown>;
68+
expect(text).toBe('{"memories": []}');
69+
expect(fetchCall?.[0]).toBe('https://chatgpt.com/backend-api/codex/responses');
70+
expect(fetchCall?.[1]?.headers).toMatchObject({
71+
Authorization: 'Bearer codex-token',
72+
'OpenAI-Account-ID': 'acct-123',
73+
Origin: 'https://chatgpt.com',
74+
});
75+
expect(requestBody.model).toBe('gpt-5.4-mini');
76+
expect(requestBody.instructions).toContain('Return only valid JSON');
77+
expect(requestBody.input).toEqual([
78+
{ type: 'message', role: 'user', content: 'User prefers concise answers.' },
79+
]);
80+
expect(requestBody.store).toBe(false);
81+
expect(requestBody.stream).toBe(true);
82+
expect(requestBody.max_output_tokens).toBeUndefined();
83+
});
84+
85+
it('rejects missing Codex auth with setup guidance', async () => {
86+
readFileMock.mockRejectedValue(new Error('ENOENT'));
87+
88+
await expect(provider().chat([{ role: 'user', content: 'hello' }]))
89+
.rejects.toThrow('Run `codex login`');
90+
});
91+
92+
it('rejects non-ChatGPT Codex auth files', async () => {
93+
readFileMock.mockResolvedValue(JSON.stringify({ auth_mode: 'apikey' }));
94+
95+
await expect(provider().chat([{ role: 'user', content: 'hello' }]))
96+
.rejects.toThrow('not a ChatGPT login');
97+
});
98+
99+
it('surfaces Codex HTTP auth failures with re-login guidance', async () => {
100+
mockAuth();
101+
vi.stubGlobal('fetch', fetchMock);
102+
fetchMock.mockResolvedValue({
103+
ok: false,
104+
status: 401,
105+
text: async () => 'unauthorized',
106+
} as Response);
107+
108+
await expect(provider().chat([{ role: 'user', content: 'hello' }]))
109+
.rejects.toThrow('Run `codex login` again');
110+
});
111+
});

packages/core/src/services/__tests__/llm-providers.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const baseConfig: LLMConfig = {
1717
groqApiKey: 'test-groq-key',
1818
llmApiUrl: undefined,
1919
llmApiKey: undefined,
20+
codexAuthPath: '/tmp/codex-auth.json',
2021
ollamaBaseUrl: 'http://localhost:11434',
2122
llmSeed: undefined,
2223
costLoggingEnabled: false,
@@ -70,7 +71,7 @@ describe('createLLMProvider', () => {
7071
expect(typeof provider.chat).toBe('function');
7172
});
7273

73-
it('creates Codex CLI provider', () => {
74+
it('creates Codex OAuth provider', () => {
7475
initLlm({ ...baseConfig, llmProvider: 'codex', llmModel: '' });
7576
const provider = createLLMProvider();
7677
expect(provider).toBeDefined();

0 commit comments

Comments
 (0)