Skip to content

Commit 561e638

Browse files
authored
Merge pull request #3 from atomicstrata/feat/codex-cli-llm-provider
Add Codex account-auth LLM provider
2 parents 9302180 + 7c17c41 commit 561e638

22 files changed

Lines changed: 765 additions & 34 deletions

packages/core/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ EMBEDDING_DIMENSIONS=1536
108108
# For fully local/no-provider-key development, pair this with a non-OpenAI
109109
# embedding provider such as EMBEDDING_PROVIDER=transformers.
110110

111+
# Personal local Codex extraction, no separate OpenAI API key:
112+
# LLM_PROVIDER=codex
113+
# Optional: defaults to CODEX_HOME/auth.json or ~/.codex/auth.json.
114+
# CODEX_AUTH_PATH=/path/to/codex/auth.json
115+
# For fully local/no-provider-key development, pair this with a non-OpenAI
116+
# embedding provider such as EMBEDDING_PROVIDER=transformers.
117+
111118
# --- Runtime config mutation (dev/test only) ---
112119
# Opt-in gate for PUT /memories/config. Leave unset in production — the
113120
# route returns 410 Gone unless this is true.

packages/core/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ COPY --from=node-base /usr/local/lib/node_modules /usr/local/lib/node_modules
5252
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
5353
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
5454

55+
RUN apt-get update && apt-get install -y --no-install-recommends \
56+
ca-certificates \
57+
&& rm -rf /var/lib/apt/lists/*
58+
5559
# Production node_modules + package.json from pnpm deploy.
5660
COPY --from=builder /deploy/node_modules ./node_modules
5761
COPY --from=builder /deploy/package.json ./package.json

packages/core/README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -297,13 +297,20 @@ 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-
301-
For personal local use, `LLM_PROVIDER=claude-code` uses the logged-in
302-
`claude` CLI session instead of requiring `ANTHROPIC_API_KEY`. It still consumes
303-
the user's Claude Code / Claude subscription limits and is not intended for
304-
hosted or team deployments. Pair it with a non-OpenAI embedding provider, such
305-
as `EMBEDDING_PROVIDER=transformers`, if you want to run without an OpenAI API
306-
key as well.
300+
| `codex` | Local Codex account session for personal development |
301+
302+
For personal local use, `LLM_PROVIDER=claude-code` and `LLM_PROVIDER=codex`
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
307+
intended for hosted or team deployments. Pair either one with a non-OpenAI
308+
embedding provider, such as `EMBEDDING_PROVIDER=transformers`, if you want to
309+
run without an OpenAI API key as well.
310+
311+
For AtomicMemory for Codex local setup, prefer `codex login` with
312+
`LLM_PROVIDER=codex`. Use `LLM_PROVIDER=openai` plus `OPENAI_API_KEY` for
313+
hosted or team deployments.
307314

308315
In-process benchmark harnesses can avoid editing env files by passing a
309316
composition-time config to the runtime:

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { DEFAULT_CODEX_LLM_MODEL } from '../services/llm-defaults.js';
67

78
const trackedEnvNames = [
89
'SIMILARITY_THRESHOLD',
@@ -18,6 +19,8 @@ const trackedEnvNames = [
1819
'RAW_STORAGE_DEPLOYMENT_ENV',
1920
'OPENAI_API_KEY',
2021
'ANTHROPIC_API_KEY',
22+
'CODEX_AUTH_PATH',
23+
'CODEX_HOME',
2124
] as const;
2225
const originalEnv = Object.fromEntries(
2326
trackedEnvNames.map((name) => [name, process.env[name]]),
@@ -89,6 +92,20 @@ describe('config env loading', () => {
8992
expect(config.llmModel).toBe('sonnet');
9093
});
9194

95+
it('accepts codex LLM provider without an OpenAI API key', async () => {
96+
process.env.LLM_PROVIDER = 'codex';
97+
process.env.EMBEDDING_PROVIDER = 'transformers';
98+
delete process.env.LLM_MODEL;
99+
delete process.env.OPENAI_API_KEY;
100+
vi.resetModules();
101+
102+
const { config } = await import('../config.js');
103+
104+
expect(config.llmProvider).toBe('codex');
105+
expect(config.llmModel).toBe(DEFAULT_CODEX_LLM_MODEL);
106+
expect(config.codexAuthPath).toContain('.codex/auth.json');
107+
});
108+
92109
it('loads optional admin cleanup endpoint config', async () => {
93110
process.env.CORE_ADMIN_API_KEY = 'test-admin-key';
94111
process.env.CORE_TEST_SCOPE_ALLOW_PATTERN = '^(smoke-|docker-).+';

packages/core/src/config.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import {
1010
type RetrievalProfile,
1111
type RetrievalProfileName,
1212
} from './services/retrieval-profiles.js';
13+
import {
14+
DEFAULT_CODEX_LLM_MODEL,
15+
DEFAULT_OPENAI_COMPATIBLE_LLM_MODEL,
16+
} from './services/llm-defaults.js';
17+
import { homedir } from 'node:os';
18+
import { join } from 'node:path';
1319
import { parsePointerUriSchemes } from './storage/pointer-uri-allowlist.js';
1420
import {
1521
collectFilecoinProviderEnvKeys,
@@ -18,7 +24,7 @@ import {
1824
} from './storage/providers/filecoin/config.js';
1925

2026
export type EmbeddingProviderName = 'openai' | 'ollama' | 'openai-compatible' | 'transformers' | 'voyage';
21-
export type LLMProviderName = EmbeddingProviderName | 'groq' | 'anthropic' | 'google-genai' | 'claude-code';
27+
export type LLMProviderName = EmbeddingProviderName | 'groq' | 'anthropic' | 'google-genai' | 'claude-code' | 'codex';
2228
export type VectorBackendName = 'pgvector' | 'ruvector-mock' | 'zvec-mock';
2329
export type CrossEncoderDtype = 'auto' | 'fp32' | 'fp16' | 'q8' | 'int8' | 'uint8' | 'q4' | 'bnb4' | 'q4f16';
2430

@@ -123,6 +129,7 @@ export interface RuntimeConfig {
123129
llmModel: string;
124130
llmApiUrl?: string;
125131
llmApiKey?: string;
132+
codexAuthPath: string;
126133
groqApiKey?: string;
127134
ollamaBaseUrl: string;
128135
vectorBackend: VectorBackendName;
@@ -687,6 +694,7 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName):
687694
'anthropic',
688695
'google-genai',
689696
'claude-code',
697+
'codex',
690698
];
691699
if (!valid.includes(value as LLMProviderName)) {
692700
throw new Error(`Invalid provider "${value}". Must be one of: ${valid.join(', ')}`);
@@ -696,7 +704,16 @@ function parseLlmProvider(value: string | undefined, fallback: LLMProviderName):
696704

697705
function defaultLlmModel(provider: LLMProviderName): string {
698706
if (provider === 'claude-code') return '';
699-
return 'gpt-4o-mini';
707+
if (provider === 'codex') return DEFAULT_CODEX_LLM_MODEL;
708+
return DEFAULT_OPENAI_COMPATIBLE_LLM_MODEL;
709+
}
710+
711+
function defaultCodexAuthPath(): string {
712+
const explicitPath = optionalEnv('CODEX_AUTH_PATH');
713+
if (explicitPath) return explicitPath;
714+
const codexHome = optionalEnv('CODEX_HOME');
715+
if (codexHome) return join(codexHome, 'auth.json');
716+
return join(homedir(), '.codex', 'auth.json');
700717
}
701718

702719

@@ -1163,6 +1180,7 @@ export const config: RuntimeConfig = {
11631180
llmModel: optionalEnv('LLM_MODEL') ?? defaultLlmModel(llmProvider),
11641181
llmApiUrl: optionalEnv('LLM_API_URL'),
11651182
llmApiKey: optionalEnv('LLM_API_KEY'),
1183+
codexAuthPath: defaultCodexAuthPath(),
11661184

11671185
// Groq
11681186
groqApiKey: groqApiKey ?? undefined,

packages/core/src/services/__tests__/claude-code-llm.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ describe('ClaudeCodeLLM', () => {
9090
});
9191

9292
await expect(provider().chat([{ role: 'user', content: 'hello' }]))
93-
.rejects.toThrow('Confirm `claude` is installed and authenticated');
93+
.rejects.toThrow('Run `claude auth login`');
94+
});
95+
96+
it('surfaces non-success result messages with setup guidance', async () => {
97+
mocks.query.mockReturnValueOnce(messages([{
98+
type: 'result',
99+
subtype: 'error',
100+
errors: ['auth required'],
101+
}]));
102+
103+
await expect(provider().chat([{ role: 'user', content: 'hello' }]))
104+
.rejects.toThrow('LLM_PROVIDER=anthropic');
94105
});
95106
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Live integration smoke for the Codex account-auth LLM provider.
3+
*
4+
* This test is default-on for developer machines where `codex login` has
5+
* created a ChatGPT auth file. Set ATOMICMEMORY_SKIP_CODEX_TEST=1 to opt out
6+
* when avoiding local account usage.
7+
*/
8+
9+
import { readFile } from 'node:fs/promises';
10+
import { homedir } from 'node:os';
11+
import { join } from 'node:path';
12+
import { describe, expect, it } from 'vitest';
13+
import { CodexLLM } from '../codex-llm.js';
14+
import { DEFAULT_CODEX_LLM_MODEL } from '../llm-defaults.js';
15+
16+
const SKIP_ENV = 'ATOMICMEMORY_SKIP_CODEX_TEST';
17+
18+
interface CodexReadiness {
19+
runnable: boolean;
20+
authPath: string;
21+
reason: string;
22+
}
23+
24+
interface CodexAuthFile {
25+
auth_mode?: string;
26+
tokens?: {
27+
access_token?: string;
28+
};
29+
}
30+
31+
const readiness = await resolveCodexReadiness();
32+
33+
describe.skipIf(!readiness.runnable)(`CodexLLM live integration (${readiness.reason})`, () => {
34+
it('runs through Codex local auth without OPENAI_API_KEY', async () => {
35+
const previousOpenAiApiKey = process.env.OPENAI_API_KEY;
36+
delete process.env.OPENAI_API_KEY;
37+
38+
try {
39+
const provider = new CodexLLM({
40+
llmProvider: 'codex',
41+
llmModel: DEFAULT_CODEX_LLM_MODEL,
42+
llmApiUrl: undefined,
43+
codexAuthPath: readiness.authPath,
44+
costLoggingEnabled: false,
45+
costRunId: 'codex-live-test',
46+
costLogDir: '/tmp/atomicmemory-codex-live-test',
47+
});
48+
49+
const output = await provider.chat([
50+
{ role: 'system', content: 'Return exactly: atomicmemory-codex-ok' },
51+
{ role: 'user', content: 'Run the AtomicMemory Codex live smoke test.' },
52+
]);
53+
54+
expect(output).toContain('atomicmemory-codex-ok');
55+
} finally {
56+
restoreEnv('OPENAI_API_KEY', previousOpenAiApiKey);
57+
}
58+
}, 120_000);
59+
});
60+
61+
async function resolveCodexReadiness(): Promise<CodexReadiness> {
62+
const authPath = process.env.CODEX_AUTH_PATH ?? join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'auth.json');
63+
if (process.env[SKIP_ENV] === '1') {
64+
return { runnable: false, authPath, reason: `${SKIP_ENV}=1` };
65+
}
66+
67+
try {
68+
const parsed = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile;
69+
if (parsed.auth_mode !== 'chatgpt') {
70+
return { runnable: false, authPath, reason: `codex auth at ${authPath} is not ChatGPT login auth` };
71+
}
72+
if (!parsed.tokens?.access_token) {
73+
return { runnable: false, authPath, reason: `codex auth at ${authPath} has no access token` };
74+
}
75+
return { runnable: true, authPath, reason: `codex auth file is present at ${authPath}` };
76+
} catch (error) {
77+
return { runnable: false, authPath, reason: `codex auth unavailable: ${errorMessage(error)}` };
78+
}
79+
}
80+
81+
function restoreEnv(name: string, value: string | undefined): void {
82+
if (value === undefined) {
83+
delete process.env[name];
84+
} else {
85+
process.env[name] = value;
86+
}
87+
}
88+
89+
function errorMessage(error: unknown): string {
90+
return error instanceof Error ? error.message : String(error);
91+
}

0 commit comments

Comments
 (0)