oh-my-harness 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/dist/cli/commands/config.d.ts +26 -0
- package/dist/cli/commands/config.js +90 -0
- package/dist/cli/commands/doctor.d.ts +7 -0
- package/dist/cli/commands/doctor.js +36 -1
- package/dist/cli/index.js +13 -0
- package/dist/cli/tui/provider-setup.js +40 -9
- package/dist/nl/config-store.d.ts +9 -2
- package/dist/nl/config-store.js +14 -0
- package/dist/nl/provider-registry.d.ts +2 -0
- package/dist/nl/provider-registry.js +68 -4
- package/dist/nl/providers/codex-oauth-api.d.ts +48 -0
- package/dist/nl/providers/codex-oauth-api.js +416 -0
- package/dist/nl/providers/codex-oauth.d.ts +5 -0
- package/dist/nl/providers/codex-oauth.js +75 -0
- package/dist/nl/providers/openai-api.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -125,7 +125,10 @@ your-project/
|
|
|
125
125
|
│ • Claude CLI │──▶│ NL Processing │◀── "React + FastAPI
|
|
126
126
|
│ • Claude API │ │ (describe your │ TDD enforced"
|
|
127
127
|
│ • OpenAI API │ │ │
|
|
128
|
-
│ • Gemini API │
|
|
128
|
+
│ • Gemini API │ │ │
|
|
129
|
+
│ • Codex OAuth │ │ │
|
|
130
|
+
│ • Codex OAuth │ │ │
|
|
131
|
+
│ API │ └────────┬────────────┘
|
|
129
132
|
└────────────────┘ │
|
|
130
133
|
(global AI config) ┌────────▼────────────┐
|
|
131
134
|
│ Project Detector │ ← Auto-detects language,
|
|
@@ -176,8 +179,10 @@ oh-my-harness supports multiple AI providers for natural language mode:
|
|
|
176
179
|
|----------|-------|------------------|---------|
|
|
177
180
|
| **Claude CLI** | `claude` command installed | Opus 4.6, Sonnet 4.6, Haiku 4.5 | ✓ |
|
|
178
181
|
| **Claude API** | Set `ANTHROPIC_API_KEY` | Opus 4.6, Sonnet 4.6, Haiku 4.5 | Sonnet 4.6 |
|
|
179
|
-
| **OpenAI API** | Set `OPENAI_API_KEY` | GPT-5.4, GPT-5.4-mini, GPT-5.4-nano, GPT-4.1, GPT-4.1-mini, o3, o4-mini | GPT-5.
|
|
182
|
+
| **OpenAI API** | Set `OPENAI_API_KEY` | GPT-5.5, GPT-5.4, GPT-5.4-mini, GPT-5.4-nano, GPT-4.1, GPT-4.1-mini, o3, o4-mini | GPT-5.5 |
|
|
180
183
|
| **Gemini API** | Set `GOOGLE_API_KEY` | Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite, Gemini 3.1 Pro Preview | Gemini 2.5 Pro |
|
|
184
|
+
| **Codex OAuth** | `codex` command installed + `codex login`; runs `codex exec` | GPT-5.5, GPT-5.4, GPT-5.4-mini | GPT-5.5 |
|
|
185
|
+
| **Codex OAuth API** | `omh config` device-code login; imports `~/.codex/auth.json` once if present, then uses `~/.omh` | GPT-5.5, GPT-5.4, GPT-5.4-mini | GPT-5.5 |
|
|
181
186
|
|
|
182
187
|
Configuration is saved to `~/.omh/config.json` and selected via interactive UI on first use:
|
|
183
188
|
|
|
@@ -452,6 +457,8 @@ oh-my-harness/
|
|
|
452
457
|
- **Node.js** >= 20
|
|
453
458
|
- **Claude CLI** (optional, for default NL mode) — [Install guide](https://docs.anthropic.com/en/docs/claude-code)
|
|
454
459
|
- **API Keys** (optional, for Claude/OpenAI/Gemini API modes) — set `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GOOGLE_API_KEY`
|
|
460
|
+
- **Codex CLI OAuth** (optional, for `codex` CLI-wrapper mode) — install `codex` and run `codex login`
|
|
461
|
+
- **Codex OAuth API** (optional, experimental direct mode) — run `omh config` and choose Codex OAuth API to complete device-code sign-in; credentials are stored under `~/.omh`
|
|
455
462
|
|
|
456
463
|
---
|
|
457
464
|
|
|
@@ -465,7 +472,7 @@ oh-my-harness/
|
|
|
465
472
|
- [x] `omh stats` — TUI analytics dashboard (ink)
|
|
466
473
|
- [x] Stateful hook logging — events.jsonl
|
|
467
474
|
- [x] TDD Guard — enforce test-first workflow
|
|
468
|
-
- [x] Multi-provider AI support — Claude API, OpenAI, Gemini
|
|
475
|
+
- [x] Multi-provider AI support — Claude API, OpenAI, Gemini, Codex OAuth
|
|
469
476
|
- [x] Interactive model selection per provider
|
|
470
477
|
- [x] GitHub star prompt — first-time only
|
|
471
478
|
- [x] Codex emitter — `AGENTS.md` + `.codex/hooks.json` + `.codex/config.toml`
|
|
@@ -473,6 +480,7 @@ oh-my-harness/
|
|
|
473
480
|
- [x] Pi ([pi.dev](https://pi.dev)) emitter — bridge extension (`.pi/extensions/omh-harness.ts`) reusing the same `.omh/hooks/*.sh`
|
|
474
481
|
- [x] `ask` mode — request approval before executing risky tools (Claude native prompt / Pi `ctx.ui.select`; Codex falls back to block)
|
|
475
482
|
- [x] `omh uninstall` — remove generated artifacts while preserving user content
|
|
483
|
+
- [x] `omh config` — view, reconfigure, or reset the saved AI provider (rotate expired keys, switch provider/model)
|
|
476
484
|
- [ ] Community harness.yaml registry — share and reuse configs
|
|
477
485
|
- [ ] `omh modify "change X"` — NL config editing
|
|
478
486
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ProviderConfig } from "../../nl/config-store.js";
|
|
2
|
+
export interface ConfigOptions {
|
|
3
|
+
/** Print the current provider config (masked) without changing anything. */
|
|
4
|
+
show?: boolean;
|
|
5
|
+
/** Delete the saved provider config. */
|
|
6
|
+
reset?: boolean;
|
|
7
|
+
/** Skip confirmation prompts. */
|
|
8
|
+
yes?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Interactive provider setup. Injectable for tests; defaults to the clack TUI.
|
|
11
|
+
* Returns the saved config, or undefined if the user cancelled.
|
|
12
|
+
*/
|
|
13
|
+
setupRunner?: () => Promise<ProviderConfig | undefined>;
|
|
14
|
+
/** Confirmation prompt. Injectable for tests; defaults to the clack TUI. */
|
|
15
|
+
confirm?: (message: string) => Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
export interface ConfigResult {
|
|
18
|
+
exitCode: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `omh config`: view, reconfigure, or reset the saved AI provider used for
|
|
22
|
+
* natural-language mode (~/.omh/config.json). Without flags it shows the current
|
|
23
|
+
* config and launches the provider setup flow so a user can rotate an expired
|
|
24
|
+
* key or switch providers.
|
|
25
|
+
*/
|
|
26
|
+
export declare function configCommand(options?: ConfigOptions): Promise<ConfigResult>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadProviderConfig, deleteProviderConfig, maskApiKey, } from "../../nl/config-store.js";
|
|
3
|
+
const NO_CONFIG_MESSAGE = "No AI provider configured yet. Run `omh config` (or `omh init`) to set one up.";
|
|
4
|
+
function printSummary(config) {
|
|
5
|
+
console.log(chalk.bold("Current AI provider configuration:"));
|
|
6
|
+
console.log(` provider: ${chalk.cyan(config.provider)}`);
|
|
7
|
+
console.log(` method: ${chalk.cyan(config.method)}`);
|
|
8
|
+
if (config.method === "api") {
|
|
9
|
+
console.log(` model: ${chalk.cyan(config.model ?? "(default)")}`);
|
|
10
|
+
console.log(` api key: ${chalk.dim(maskApiKey(config.apiKey))}`);
|
|
11
|
+
}
|
|
12
|
+
else if (config.method === "oauth") {
|
|
13
|
+
console.log(` model: ${chalk.cyan(config.model ?? "(default)")}`);
|
|
14
|
+
console.log(` command: ${chalk.cyan(config.cliCommand ?? config.provider)}`);
|
|
15
|
+
console.log(` auth: ${chalk.dim("uses Codex CLI OAuth session; run `codex login` to sign in")}`);
|
|
16
|
+
}
|
|
17
|
+
else if (config.method === "oauth-api") {
|
|
18
|
+
console.log(` model: ${chalk.cyan(config.model ?? "(default)")}`);
|
|
19
|
+
console.log(` auth: ${chalk.dim("uses Codex OAuth token from ~/.omh; run `omh config` to sign in or refresh")}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.log(` command: ${chalk.cyan(config.cliCommand ?? config.provider)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function defaultConfirm(message) {
|
|
26
|
+
const p = await import("@clack/prompts");
|
|
27
|
+
const answer = await p.confirm({ message });
|
|
28
|
+
if (p.isCancel(answer))
|
|
29
|
+
return false;
|
|
30
|
+
return answer === true;
|
|
31
|
+
}
|
|
32
|
+
async function defaultSetupRunner() {
|
|
33
|
+
const { runProviderSetup } = await import("../tui/provider-setup.js");
|
|
34
|
+
return runProviderSetup();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `omh config`: view, reconfigure, or reset the saved AI provider used for
|
|
38
|
+
* natural-language mode (~/.omh/config.json). Without flags it shows the current
|
|
39
|
+
* config and launches the provider setup flow so a user can rotate an expired
|
|
40
|
+
* key or switch providers.
|
|
41
|
+
*/
|
|
42
|
+
export async function configCommand(options = {}) {
|
|
43
|
+
// --show and --reset express contradictory intent; fail loudly rather than
|
|
44
|
+
// silently letting one win.
|
|
45
|
+
if (options.show && options.reset) {
|
|
46
|
+
console.error("`--show` and `--reset` cannot be used together.");
|
|
47
|
+
return { exitCode: 1 };
|
|
48
|
+
}
|
|
49
|
+
const existing = await loadProviderConfig();
|
|
50
|
+
// --show: read-only summary.
|
|
51
|
+
if (options.show) {
|
|
52
|
+
if (!existing) {
|
|
53
|
+
console.log(NO_CONFIG_MESSAGE);
|
|
54
|
+
return { exitCode: 0 };
|
|
55
|
+
}
|
|
56
|
+
printSummary(existing);
|
|
57
|
+
return { exitCode: 0 };
|
|
58
|
+
}
|
|
59
|
+
// --reset: delete the saved config.
|
|
60
|
+
if (options.reset) {
|
|
61
|
+
if (!existing) {
|
|
62
|
+
console.log(NO_CONFIG_MESSAGE);
|
|
63
|
+
return { exitCode: 0 };
|
|
64
|
+
}
|
|
65
|
+
const confirm = options.confirm ?? defaultConfirm;
|
|
66
|
+
const ok = options.yes ? true : await confirm("Delete the saved AI provider configuration?");
|
|
67
|
+
if (!ok) {
|
|
68
|
+
console.log("Aborted. Configuration left unchanged.");
|
|
69
|
+
return { exitCode: 0 };
|
|
70
|
+
}
|
|
71
|
+
await deleteProviderConfig();
|
|
72
|
+
console.log(chalk.green("Removed ~/.omh/config.json"));
|
|
73
|
+
return { exitCode: 0 };
|
|
74
|
+
}
|
|
75
|
+
// Default: show current config (if any), then reconfigure.
|
|
76
|
+
if (existing) {
|
|
77
|
+
printSummary(existing);
|
|
78
|
+
console.log("");
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log("No AI provider configured yet. Let's set one up.\n");
|
|
82
|
+
}
|
|
83
|
+
const setupRunner = options.setupRunner ?? defaultSetupRunner;
|
|
84
|
+
const updated = await setupRunner();
|
|
85
|
+
if (!updated) {
|
|
86
|
+
// Setup was cancelled; nothing changed.
|
|
87
|
+
return { exitCode: 0 };
|
|
88
|
+
}
|
|
89
|
+
return { exitCode: 0 };
|
|
90
|
+
}
|
|
@@ -21,6 +21,13 @@ export interface DoctorResult {
|
|
|
21
21
|
* warning unless `strict` is set.
|
|
22
22
|
*/
|
|
23
23
|
inSync?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether an AI provider is configured for natural-language mode
|
|
26
|
+
* (~/.omh/config.json). Informational only — it never affects health, since
|
|
27
|
+
* the provider is global and optional. Surfaced so users can discover
|
|
28
|
+
* `omh config` to rotate an expired key or switch provider/model.
|
|
29
|
+
*/
|
|
30
|
+
providerConfigured: boolean;
|
|
24
31
|
messages: string[];
|
|
25
32
|
}
|
|
26
33
|
export declare function doctorCommand(options?: DoctorOptions): Promise<DoctorResult>;
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { parse } from "smol-toml";
|
|
4
4
|
import { OMH_HOOKS_DIR } from "../../utils/paths.js";
|
|
5
5
|
import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
|
|
6
|
+
import { loadProviderConfig } from "../../nl/config-store.js";
|
|
6
7
|
export async function doctorCommand(options = {}) {
|
|
7
8
|
const projectDir = options.projectDir ?? process.cwd();
|
|
8
9
|
const messages = [];
|
|
@@ -154,6 +155,10 @@ export async function doctorCommand(options = {}) {
|
|
|
154
155
|
}
|
|
155
156
|
// No harness.yaml → leave inSync undefined (drift not applicable).
|
|
156
157
|
}
|
|
158
|
+
// 9. AI provider config (~/.omh/config.json). Global and optional, so this is
|
|
159
|
+
// purely informational (INFO) and never affects health — it just surfaces
|
|
160
|
+
// `omh config` so users can rotate an expired key or switch provider/model.
|
|
161
|
+
const providerConfigured = await checkProviderConfig(messages);
|
|
157
162
|
const checksHealthy = Object.values(checks).every(Boolean);
|
|
158
163
|
const healthy = checksHealthy && (!options.strict || inSync !== false);
|
|
159
164
|
const exitCode = healthy ? 0 : 1;
|
|
@@ -169,5 +174,35 @@ export async function doctorCommand(options = {}) {
|
|
|
169
174
|
console.log(` ${msg}`);
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
|
-
return { healthy, exitCode, checks, inSync, messages };
|
|
177
|
+
return { healthy, exitCode, checks, inSync, providerConfigured, messages };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Inspects the global AI provider config and appends an INFO hint to `messages`.
|
|
181
|
+
* Returns whether a provider is configured. Never reads or echoes the API key.
|
|
182
|
+
*/
|
|
183
|
+
async function checkProviderConfig(messages) {
|
|
184
|
+
const config = await loadProviderConfig();
|
|
185
|
+
if (!config) {
|
|
186
|
+
messages.push("INFO: No AI provider configured for natural-language mode — run `omh config` to set one up.");
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (config.method === "api") {
|
|
190
|
+
const model = config.model ? `, ${config.model}` : "";
|
|
191
|
+
messages.push(`INFO: AI provider: ${config.provider} (api${model}). ` +
|
|
192
|
+
"If your API key expired, run `omh config` to update it or switch provider.");
|
|
193
|
+
}
|
|
194
|
+
else if (config.method === "oauth") {
|
|
195
|
+
const model = config.model ? `, ${config.model}` : "";
|
|
196
|
+
messages.push(`INFO: AI provider: ${config.provider} (oauth${model}). ` +
|
|
197
|
+
"Uses Codex CLI auth; run `codex login` if the session expired.");
|
|
198
|
+
}
|
|
199
|
+
else if (config.method === "oauth-api") {
|
|
200
|
+
const model = config.model ? `, ${config.model}` : "";
|
|
201
|
+
messages.push(`INFO: AI provider: ${config.provider} (oauth-api${model}). ` +
|
|
202
|
+
"Uses ~/.omh Codex OAuth auth store; run `omh config` if the token expired.");
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
messages.push(`INFO: AI provider: ${config.provider} (cli). Run \`omh config\` to switch provider or model.`);
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
173
208
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -87,6 +87,19 @@ export function createCli() {
|
|
|
87
87
|
process.exitCode = result.exitCode;
|
|
88
88
|
}
|
|
89
89
|
});
|
|
90
|
+
program
|
|
91
|
+
.command("config")
|
|
92
|
+
.description("View, reconfigure, or reset the saved AI provider")
|
|
93
|
+
.option("--show", "Show the current provider config (API key masked)")
|
|
94
|
+
.option("--reset", "Delete the saved provider config")
|
|
95
|
+
.option("-y, --yes", "Skip confirmation prompts")
|
|
96
|
+
.action(async (options) => {
|
|
97
|
+
const { configCommand } = await import("./commands/config.js");
|
|
98
|
+
const result = await configCommand(options);
|
|
99
|
+
if (result.exitCode !== 0) {
|
|
100
|
+
process.exitCode = result.exitCode;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
90
103
|
program
|
|
91
104
|
.command("stats")
|
|
92
105
|
.description("TUI dashboard for harness analytics")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
2
|
import { getAvailableProviders, getProviderDefinition, } from "../../nl/provider-registry.js";
|
|
3
|
+
import { ensureCodexOauthApiAuth } from "../../nl/providers/codex-oauth-api.js";
|
|
3
4
|
import { saveProviderConfig, } from "../../nl/config-store.js";
|
|
4
5
|
export async function runProviderSetup() {
|
|
5
6
|
p.intro("AI Provider Setup");
|
|
@@ -17,15 +18,18 @@ export async function runProviderSetup() {
|
|
|
17
18
|
return undefined;
|
|
18
19
|
}
|
|
19
20
|
const def = getProviderDefinition(providerName);
|
|
20
|
-
// Step 2: Select method (CLI or
|
|
21
|
+
// Step 2: Select method (CLI, API, or OAuth)
|
|
21
22
|
let method;
|
|
22
|
-
|
|
23
|
+
const methodOptions = [
|
|
24
|
+
def.supportsCli ? { value: "cli", label: `CLI tool (${def.cliCommand ?? def.name})` } : undefined,
|
|
25
|
+
def.supportsApi ? { value: "api", label: "API Key" } : undefined,
|
|
26
|
+
def.supportsOAuth ? { value: "oauth", label: `Codex OAuth (${def.cliCommand ?? def.name} login)` } : undefined,
|
|
27
|
+
def.supportsOAuthApi ? { value: "oauth-api", label: "Codex OAuth API (~/.omh auth store)" } : undefined,
|
|
28
|
+
].filter((option) => option !== undefined);
|
|
29
|
+
if (methodOptions.length > 1) {
|
|
23
30
|
const selected = await p.select({
|
|
24
31
|
message: "How would you like to connect?",
|
|
25
|
-
options:
|
|
26
|
-
{ value: "cli", label: `CLI tool (${def.cliCommand ?? def.name})` },
|
|
27
|
-
{ value: "api", label: "API Key" },
|
|
28
|
-
],
|
|
32
|
+
options: methodOptions,
|
|
29
33
|
});
|
|
30
34
|
if (p.isCancel(selected)) {
|
|
31
35
|
p.cancel("Provider setup cancelled.");
|
|
@@ -33,11 +37,11 @@ export async function runProviderSetup() {
|
|
|
33
37
|
}
|
|
34
38
|
method = selected;
|
|
35
39
|
}
|
|
36
|
-
else if (
|
|
37
|
-
method =
|
|
40
|
+
else if (methodOptions[0]) {
|
|
41
|
+
method = methodOptions[0].value;
|
|
38
42
|
}
|
|
39
43
|
else {
|
|
40
|
-
|
|
44
|
+
throw new Error(`Provider "${def.name}" has no supported authentication method`);
|
|
41
45
|
}
|
|
42
46
|
const config = {
|
|
43
47
|
provider: providerName,
|
|
@@ -75,6 +79,33 @@ export async function runProviderSetup() {
|
|
|
75
79
|
}
|
|
76
80
|
config.model = selectedModel;
|
|
77
81
|
}
|
|
82
|
+
else if (method === "oauth" || method === "oauth-api") {
|
|
83
|
+
if (method === "oauth") {
|
|
84
|
+
config.cliCommand = def.cliCommand ?? def.name;
|
|
85
|
+
}
|
|
86
|
+
const selectedModel = await p.select({
|
|
87
|
+
message: "Select model:",
|
|
88
|
+
options: def.availableModels.map((m) => ({
|
|
89
|
+
value: m.id,
|
|
90
|
+
label: m.label,
|
|
91
|
+
hint: m.id === def.defaultModel ? "default" : undefined,
|
|
92
|
+
})),
|
|
93
|
+
initialValue: def.defaultModel,
|
|
94
|
+
});
|
|
95
|
+
if (p.isCancel(selectedModel)) {
|
|
96
|
+
p.cancel("Provider setup cancelled.");
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
config.model = selectedModel;
|
|
100
|
+
if (method === "oauth-api") {
|
|
101
|
+
await ensureCodexOauthApiAuth({
|
|
102
|
+
onDeviceCode: ({ url, code }) => {
|
|
103
|
+
p.note(`Open ${url} and enter code: ${code}`, "Codex OAuth API sign-in");
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
p.log.success("Codex OAuth API session saved under ~/.omh.");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
78
109
|
else {
|
|
79
110
|
config.cliCommand = def.cliCommand ?? def.name;
|
|
80
111
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export interface ProviderConfig {
|
|
2
|
-
provider: "claude" | "openai" | "gemini";
|
|
3
|
-
method: "cli" | "api";
|
|
2
|
+
provider: "claude" | "openai" | "gemini" | "codex" | "codex-oauth-api";
|
|
3
|
+
method: "cli" | "api" | "oauth" | "oauth-api";
|
|
4
4
|
apiKey?: string;
|
|
5
5
|
model?: string;
|
|
6
6
|
cliCommand?: string;
|
|
@@ -9,3 +9,10 @@ export declare function getConfigDir(): string;
|
|
|
9
9
|
export declare function hasProviderConfig(): Promise<boolean>;
|
|
10
10
|
export declare function loadProviderConfig(): Promise<ProviderConfig | undefined>;
|
|
11
11
|
export declare function saveProviderConfig(config: ProviderConfig): Promise<void>;
|
|
12
|
+
export declare function deleteProviderConfig(): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Masks an API key for display, keeping a short prefix and suffix so the user
|
|
15
|
+
* can recognize which key is stored without revealing it. Keys short enough
|
|
16
|
+
* that a prefix/suffix would overlap are masked entirely.
|
|
17
|
+
*/
|
|
18
|
+
export declare function maskApiKey(apiKey: string | undefined): string;
|
package/dist/nl/config-store.js
CHANGED
|
@@ -34,3 +34,17 @@ export async function saveProviderConfig(config) {
|
|
|
34
34
|
await fs.writeFile(configPath, payload, { encoding: "utf-8", mode: 0o600 });
|
|
35
35
|
await fs.chmod(configPath, 0o600);
|
|
36
36
|
}
|
|
37
|
+
export async function deleteProviderConfig() {
|
|
38
|
+
await fs.rm(getConfigPath(), { force: true });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Masks an API key for display, keeping a short prefix and suffix so the user
|
|
42
|
+
* can recognize which key is stored without revealing it. Keys short enough
|
|
43
|
+
* that a prefix/suffix would overlap are masked entirely.
|
|
44
|
+
*/
|
|
45
|
+
export function maskApiKey(apiKey) {
|
|
46
|
+
const key = apiKey?.trim() ?? "";
|
|
47
|
+
if (key.length <= 8)
|
|
48
|
+
return "****";
|
|
49
|
+
return `${key.slice(0, 3)}…${key.slice(-4)}`;
|
|
50
|
+
}
|
|
@@ -2,12 +2,16 @@ import { createClaudeCliProvider } from "./providers/claude-cli.js";
|
|
|
2
2
|
import { createClaudeApiProvider } from "./providers/claude-api.js";
|
|
3
3
|
import { createOpenaiApiProvider } from "./providers/openai-api.js";
|
|
4
4
|
import { createGeminiApiProvider } from "./providers/gemini-api.js";
|
|
5
|
+
import { createCodexOauthProvider } from "./providers/codex-oauth.js";
|
|
6
|
+
import { createCodexOauthApiProvider } from "./providers/codex-oauth-api.js";
|
|
5
7
|
const providers = [
|
|
6
8
|
{
|
|
7
9
|
name: "claude",
|
|
8
10
|
displayName: "Claude (Anthropic)",
|
|
9
11
|
supportsCli: true,
|
|
10
12
|
supportsApi: true,
|
|
13
|
+
supportsOAuth: false,
|
|
14
|
+
supportsOAuthApi: false,
|
|
11
15
|
defaultModel: "claude-sonnet-4-6",
|
|
12
16
|
availableModels: [
|
|
13
17
|
{ id: "claude-opus-4-6", label: "Claude Opus 4.6 — most capable, 1M context" },
|
|
@@ -18,12 +22,15 @@ const providers = [
|
|
|
18
22
|
},
|
|
19
23
|
{
|
|
20
24
|
name: "openai",
|
|
21
|
-
displayName: "OpenAI (GPT-5.
|
|
25
|
+
displayName: "OpenAI (GPT-5.5)",
|
|
22
26
|
supportsCli: false,
|
|
23
27
|
supportsApi: true,
|
|
24
|
-
|
|
28
|
+
supportsOAuth: false,
|
|
29
|
+
supportsOAuthApi: false,
|
|
30
|
+
defaultModel: "gpt-5.5",
|
|
25
31
|
availableModels: [
|
|
26
|
-
{ id: "gpt-5.
|
|
32
|
+
{ id: "gpt-5.5", label: "GPT-5.5 — newest frontier, complex reasoning & coding" },
|
|
33
|
+
{ id: "gpt-5.4", label: "GPT-5.4 — previous flagship, agentic & coding" },
|
|
27
34
|
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini — strongest mini model" },
|
|
28
35
|
{ id: "gpt-5.4-nano", label: "GPT-5.4 Nano — cheapest GPT-5.4 class" },
|
|
29
36
|
{ id: "gpt-4.1", label: "GPT-4.1 — best non-reasoning, coding" },
|
|
@@ -37,6 +44,8 @@ const providers = [
|
|
|
37
44
|
displayName: "Gemini (Google)",
|
|
38
45
|
supportsCli: false,
|
|
39
46
|
supportsApi: true,
|
|
47
|
+
supportsOAuth: false,
|
|
48
|
+
supportsOAuthApi: false,
|
|
40
49
|
defaultModel: "gemini-2.5-pro",
|
|
41
50
|
availableModels: [
|
|
42
51
|
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro — most advanced stable" },
|
|
@@ -46,6 +55,35 @@ const providers = [
|
|
|
46
55
|
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview — frontier performance (preview)" },
|
|
47
56
|
],
|
|
48
57
|
},
|
|
58
|
+
{
|
|
59
|
+
name: "codex",
|
|
60
|
+
displayName: "Codex OAuth (OpenAI ChatGPT login)",
|
|
61
|
+
supportsCli: false,
|
|
62
|
+
supportsApi: false,
|
|
63
|
+
supportsOAuth: true,
|
|
64
|
+
supportsOAuthApi: false,
|
|
65
|
+
defaultModel: "gpt-5.5",
|
|
66
|
+
availableModels: [
|
|
67
|
+
{ id: "gpt-5.5", label: "GPT-5.5 — frontier Codex reasoning when available" },
|
|
68
|
+
{ id: "gpt-5.4", label: "GPT-5.4 — previous Codex default-capable flagship" },
|
|
69
|
+
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini — faster Codex runs" },
|
|
70
|
+
],
|
|
71
|
+
cliCommand: "codex",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "codex-oauth-api",
|
|
75
|
+
displayName: "Codex OAuth API (ChatGPT token direct)",
|
|
76
|
+
supportsCli: false,
|
|
77
|
+
supportsApi: false,
|
|
78
|
+
supportsOAuth: false,
|
|
79
|
+
supportsOAuthApi: true,
|
|
80
|
+
defaultModel: "gpt-5.5",
|
|
81
|
+
availableModels: [
|
|
82
|
+
{ id: "gpt-5.5", label: "GPT-5.5 — direct Codex OAuth Responses endpoint" },
|
|
83
|
+
{ id: "gpt-5.4", label: "GPT-5.4 — previous direct Codex OAuth model" },
|
|
84
|
+
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini — faster direct Codex OAuth runs" },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
49
87
|
];
|
|
50
88
|
export function getAvailableProviders() {
|
|
51
89
|
return [...providers];
|
|
@@ -62,15 +100,41 @@ export function createProvider(config) {
|
|
|
62
100
|
if (!def) {
|
|
63
101
|
throw new Error(`Unknown AI provider: "${config.provider}". Available: ${providers.map((p) => p.name).join(", ")}`);
|
|
64
102
|
}
|
|
65
|
-
if (config.method !== "cli" && config.method !== "api") {
|
|
103
|
+
if (config.method !== "cli" && config.method !== "api" && config.method !== "oauth" && config.method !== "oauth-api") {
|
|
66
104
|
throw new Error(`Unsupported provider method: "${String(config.method)}"`);
|
|
67
105
|
}
|
|
106
|
+
if (config.method === "cli" && !def.supportsCli) {
|
|
107
|
+
throw new Error(`Provider "${config.provider}" does not support CLI mode`);
|
|
108
|
+
}
|
|
109
|
+
if (config.method === "api" && !def.supportsApi) {
|
|
110
|
+
throw new Error(`Provider "${config.provider}" does not support API mode`);
|
|
111
|
+
}
|
|
112
|
+
if (config.method === "oauth" && !def.supportsOAuth) {
|
|
113
|
+
throw new Error(`Provider "${config.provider}" does not support OAuth mode`);
|
|
114
|
+
}
|
|
115
|
+
if (config.method === "oauth-api" && !def.supportsOAuthApi) {
|
|
116
|
+
throw new Error(`Provider "${config.provider}" does not support OAuth API mode`);
|
|
117
|
+
}
|
|
68
118
|
if (config.method === "cli") {
|
|
69
119
|
if (config.provider === "claude") {
|
|
70
120
|
return createClaudeCliProvider(config.cliCommand ?? "claude");
|
|
71
121
|
}
|
|
72
122
|
throw new Error(`Provider "${config.provider}" does not support CLI mode`);
|
|
73
123
|
}
|
|
124
|
+
if (config.method === "oauth") {
|
|
125
|
+
if (config.provider === "codex") {
|
|
126
|
+
const model = config.model?.trim() || def.defaultModel;
|
|
127
|
+
return createCodexOauthProvider(config.cliCommand ?? "codex", model);
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Provider "${config.provider}" does not support OAuth mode`);
|
|
130
|
+
}
|
|
131
|
+
if (config.method === "oauth-api") {
|
|
132
|
+
if (config.provider === "codex-oauth-api") {
|
|
133
|
+
const model = config.model?.trim() || def.defaultModel;
|
|
134
|
+
return createCodexOauthApiProvider({ model });
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Provider "${config.provider}" does not support OAuth API mode`);
|
|
137
|
+
}
|
|
74
138
|
// API mode
|
|
75
139
|
const apiKey = config.apiKey?.trim();
|
|
76
140
|
if (!apiKey) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { LLMProvider } from "../provider-registry.js";
|
|
2
|
+
export interface CodexOauthApiProviderOptions {
|
|
3
|
+
model?: string;
|
|
4
|
+
authPath?: string;
|
|
5
|
+
codexCliAuthPath?: string;
|
|
6
|
+
responsesUrl?: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface CodexOauthApiLoginOptions {
|
|
10
|
+
authPath?: string;
|
|
11
|
+
issuer?: string;
|
|
12
|
+
tokenUrl?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
pollIntervalMs?: number;
|
|
15
|
+
maxWaitMs?: number;
|
|
16
|
+
onDeviceCode?: (info: {
|
|
17
|
+
url: string;
|
|
18
|
+
code: string;
|
|
19
|
+
}) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
interface CodexAuthFile {
|
|
22
|
+
provider?: string;
|
|
23
|
+
source?: string;
|
|
24
|
+
OPENAI_API_KEY?: string | null;
|
|
25
|
+
tokens?: {
|
|
26
|
+
access_token?: string;
|
|
27
|
+
refresh_token?: string;
|
|
28
|
+
id_token?: string;
|
|
29
|
+
account_id?: string;
|
|
30
|
+
};
|
|
31
|
+
last_refresh?: string;
|
|
32
|
+
auth_mode?: string;
|
|
33
|
+
}
|
|
34
|
+
interface AuthState {
|
|
35
|
+
accessToken: string;
|
|
36
|
+
refreshToken?: string;
|
|
37
|
+
idToken?: string;
|
|
38
|
+
accountId?: string;
|
|
39
|
+
authPath?: string;
|
|
40
|
+
authFile?: CodexAuthFile;
|
|
41
|
+
}
|
|
42
|
+
export declare function getCodexOauthApiAuthPath(): string;
|
|
43
|
+
export declare function loginCodexOauthApi(options?: CodexOauthApiLoginOptions): Promise<AuthState>;
|
|
44
|
+
export declare function ensureCodexOauthApiAuth(options?: CodexOauthApiLoginOptions & {
|
|
45
|
+
codexCliAuthPath?: string;
|
|
46
|
+
}): Promise<AuthState>;
|
|
47
|
+
export declare function createCodexOauthApiProvider(options?: CodexOauthApiProviderOptions): LLMProvider;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getConfigDir } from "../config-store.js";
|
|
5
|
+
const DEFAULT_MODEL = "gpt-5.5";
|
|
6
|
+
const ISSUER = "https://auth.openai.com";
|
|
7
|
+
const RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
8
|
+
const TOKEN_URL = `${ISSUER}/oauth/token`;
|
|
9
|
+
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
11
|
+
const MAX_ATTEMPTS = 3;
|
|
12
|
+
const AUTH_FILENAME = "codex-oauth-api-auth.json";
|
|
13
|
+
export function getCodexOauthApiAuthPath() {
|
|
14
|
+
return path.join(getConfigDir(), AUTH_FILENAME);
|
|
15
|
+
}
|
|
16
|
+
function getCodexCliAuthPath() {
|
|
17
|
+
const codexHome = process.env.CODEX_HOME?.trim() || path.join(process.env.HOME ?? os.homedir(), ".codex");
|
|
18
|
+
return path.join(codexHome, "auth.json");
|
|
19
|
+
}
|
|
20
|
+
function decodeJwtPayload(token) {
|
|
21
|
+
const payload = token?.split(".")[1];
|
|
22
|
+
if (!payload)
|
|
23
|
+
return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const padded = `${payload}${"=".repeat((4 - (payload.length % 4)) % 4)}`;
|
|
26
|
+
return JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function readNestedAccountId(payload) {
|
|
33
|
+
if (!payload)
|
|
34
|
+
return undefined;
|
|
35
|
+
const authClaim = payload["https://api.openai.com/auth"];
|
|
36
|
+
const nested = authClaim && typeof authClaim === "object"
|
|
37
|
+
? authClaim.chatgpt_account_id
|
|
38
|
+
: undefined;
|
|
39
|
+
const dotted = payload["https://api.openai.com/auth.chatgpt_account_id"];
|
|
40
|
+
const direct = payload.chatgpt_account_id;
|
|
41
|
+
const orgs = payload.organizations;
|
|
42
|
+
const firstOrg = Array.isArray(orgs) ? orgs[0]?.id : undefined;
|
|
43
|
+
const candidate = nested ?? dotted ?? direct ?? firstOrg;
|
|
44
|
+
return typeof candidate === "string" && candidate.trim() ? candidate : undefined;
|
|
45
|
+
}
|
|
46
|
+
function extractAccountId(tokens) {
|
|
47
|
+
return tokens?.account_id
|
|
48
|
+
?? readNestedAccountId(decodeJwtPayload(tokens?.id_token))
|
|
49
|
+
?? readNestedAccountId(decodeJwtPayload(tokens?.access_token));
|
|
50
|
+
}
|
|
51
|
+
async function readAuthFile(authPath) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(await fs.readFile(authPath, "utf-8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function writeAuthFile(authPath, authFile) {
|
|
60
|
+
await fs.mkdir(path.dirname(authPath), { recursive: true });
|
|
61
|
+
await fs.writeFile(authPath, `${JSON.stringify(authFile, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
62
|
+
await fs.chmod(authPath, 0o600);
|
|
63
|
+
}
|
|
64
|
+
function authStateFromFile(authFile, authPath) {
|
|
65
|
+
const accessToken = authFile.tokens?.access_token?.trim();
|
|
66
|
+
if (!accessToken)
|
|
67
|
+
return undefined;
|
|
68
|
+
return {
|
|
69
|
+
accessToken,
|
|
70
|
+
refreshToken: authFile.tokens?.refresh_token,
|
|
71
|
+
idToken: authFile.tokens?.id_token,
|
|
72
|
+
accountId: extractAccountId(authFile.tokens),
|
|
73
|
+
authPath,
|
|
74
|
+
authFile,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function importCodexCliAuth(cliAuthPath, authPath) {
|
|
78
|
+
const cliAuth = await readAuthFile(cliAuthPath);
|
|
79
|
+
const state = cliAuth ? authStateFromFile(cliAuth, authPath) : undefined;
|
|
80
|
+
if (!state || !cliAuth?.tokens)
|
|
81
|
+
return undefined;
|
|
82
|
+
const nextAuth = {
|
|
83
|
+
provider: "codex-oauth-api",
|
|
84
|
+
source: "codex-cli-import",
|
|
85
|
+
auth_mode: "chatgpt",
|
|
86
|
+
tokens: {
|
|
87
|
+
...cliAuth.tokens,
|
|
88
|
+
account_id: extractAccountId(cliAuth.tokens),
|
|
89
|
+
},
|
|
90
|
+
last_refresh: new Date().toISOString(),
|
|
91
|
+
};
|
|
92
|
+
await writeAuthFile(authPath, nextAuth);
|
|
93
|
+
return authStateFromFile(nextAuth, authPath);
|
|
94
|
+
}
|
|
95
|
+
async function loadAuthState(authPath, cliAuthPath) {
|
|
96
|
+
const envToken = process.env.CODEX_ACCESS_TOKEN?.trim() || process.env.CODEX_API_KEY?.trim();
|
|
97
|
+
if (envToken) {
|
|
98
|
+
return {
|
|
99
|
+
accessToken: envToken,
|
|
100
|
+
accountId: readNestedAccountId(decodeJwtPayload(envToken)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const omhAuth = await readAuthFile(authPath);
|
|
104
|
+
const omhState = omhAuth ? authStateFromFile(omhAuth, authPath) : undefined;
|
|
105
|
+
if (omhState)
|
|
106
|
+
return omhState;
|
|
107
|
+
const imported = await importCodexCliAuth(cliAuthPath, authPath);
|
|
108
|
+
if (imported)
|
|
109
|
+
return imported;
|
|
110
|
+
throw new Error(`Codex OAuth API credentials not found. Run \`omh config\` and choose Codex OAuth API to sign in, ` +
|
|
111
|
+
`or set CODEX_ACCESS_TOKEN.`);
|
|
112
|
+
}
|
|
113
|
+
async function persistRefreshedAuth(state, tokenResponse) {
|
|
114
|
+
if (!state.authPath || !state.authFile || !tokenResponse.access_token) {
|
|
115
|
+
return state;
|
|
116
|
+
}
|
|
117
|
+
const nextTokens = {
|
|
118
|
+
...state.authFile.tokens,
|
|
119
|
+
access_token: tokenResponse.access_token,
|
|
120
|
+
refresh_token: tokenResponse.refresh_token ?? state.refreshToken,
|
|
121
|
+
id_token: tokenResponse.id_token ?? state.idToken,
|
|
122
|
+
};
|
|
123
|
+
const accountId = extractAccountId(nextTokens) ?? state.accountId;
|
|
124
|
+
if (accountId)
|
|
125
|
+
nextTokens.account_id = accountId;
|
|
126
|
+
const nextAuth = {
|
|
127
|
+
...state.authFile,
|
|
128
|
+
provider: "codex-oauth-api",
|
|
129
|
+
auth_mode: "chatgpt",
|
|
130
|
+
tokens: nextTokens,
|
|
131
|
+
last_refresh: new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
await writeAuthFile(state.authPath, nextAuth);
|
|
134
|
+
return {
|
|
135
|
+
accessToken: tokenResponse.access_token,
|
|
136
|
+
refreshToken: nextTokens.refresh_token,
|
|
137
|
+
idToken: nextTokens.id_token,
|
|
138
|
+
accountId,
|
|
139
|
+
authPath: state.authPath,
|
|
140
|
+
authFile: nextAuth,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function refreshAuth(state, timeoutMs) {
|
|
144
|
+
if (!state.refreshToken) {
|
|
145
|
+
throw new Error("Codex OAuth API token expired and no refresh token is available. Run `omh config` again.");
|
|
146
|
+
}
|
|
147
|
+
const response = await fetchWithTimeout(TOKEN_URL, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
150
|
+
body: new URLSearchParams({
|
|
151
|
+
client_id: CODEX_CLIENT_ID,
|
|
152
|
+
grant_type: "refresh_token",
|
|
153
|
+
refresh_token: state.refreshToken,
|
|
154
|
+
}).toString(),
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new Error(`Codex OAuth token refresh failed (${response.status}): ${await response.text()}`);
|
|
158
|
+
}
|
|
159
|
+
const tokenResponse = (await response.json());
|
|
160
|
+
if (!tokenResponse.access_token) {
|
|
161
|
+
throw new Error("Codex OAuth token refresh returned no access token");
|
|
162
|
+
}
|
|
163
|
+
return persistRefreshedAuth(state, tokenResponse);
|
|
164
|
+
}
|
|
165
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
168
|
+
try {
|
|
169
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (err.name === "AbortError") {
|
|
173
|
+
throw new Error(`Codex OAuth API request timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
|
|
174
|
+
}
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(timeout);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function buildHeaders(state) {
|
|
182
|
+
const headers = {
|
|
183
|
+
"Authorization": `Bearer ${state.accessToken}`,
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
"Accept": "text/event-stream, application/json",
|
|
186
|
+
"originator": "codex_cli_rs",
|
|
187
|
+
"User-Agent": "codex_cli_rs/0.0.1",
|
|
188
|
+
};
|
|
189
|
+
if (state.accountId) {
|
|
190
|
+
headers["ChatGPT-Account-ID"] = state.accountId;
|
|
191
|
+
}
|
|
192
|
+
return headers;
|
|
193
|
+
}
|
|
194
|
+
function buildBody(prompt, model) {
|
|
195
|
+
return JSON.stringify({
|
|
196
|
+
model,
|
|
197
|
+
instructions: "You are a concise assistant. Return only the requested answer.",
|
|
198
|
+
input: [
|
|
199
|
+
{
|
|
200
|
+
role: "user",
|
|
201
|
+
content: [{ type: "input_text", text: prompt }],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
store: false,
|
|
205
|
+
stream: true,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function extractTextFromJson(data) {
|
|
209
|
+
if (typeof data !== "object" || data === null)
|
|
210
|
+
return "";
|
|
211
|
+
const root = data;
|
|
212
|
+
if (typeof root.output_text === "string")
|
|
213
|
+
return root.output_text;
|
|
214
|
+
if (typeof root.delta === "string")
|
|
215
|
+
return root.delta;
|
|
216
|
+
if (typeof root.text === "string")
|
|
217
|
+
return root.text;
|
|
218
|
+
if (root.response) {
|
|
219
|
+
const nested = extractTextFromJson(root.response);
|
|
220
|
+
if (nested)
|
|
221
|
+
return nested;
|
|
222
|
+
}
|
|
223
|
+
const output = Array.isArray(root.output) ? root.output : [];
|
|
224
|
+
const parts = [];
|
|
225
|
+
for (const item of output) {
|
|
226
|
+
if (typeof item !== "object" || item === null)
|
|
227
|
+
continue;
|
|
228
|
+
const outputItem = item;
|
|
229
|
+
if (typeof outputItem.text === "string")
|
|
230
|
+
parts.push(outputItem.text);
|
|
231
|
+
const content = Array.isArray(outputItem.content) ? outputItem.content : [];
|
|
232
|
+
for (const contentItem of content) {
|
|
233
|
+
if (typeof contentItem !== "object" || contentItem === null)
|
|
234
|
+
continue;
|
|
235
|
+
const maybeText = contentItem.text
|
|
236
|
+
?? contentItem.output_text;
|
|
237
|
+
if (typeof maybeText === "string")
|
|
238
|
+
parts.push(maybeText);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return parts.join("");
|
|
242
|
+
}
|
|
243
|
+
function parseResponseText(raw) {
|
|
244
|
+
const trimmed = raw.trim();
|
|
245
|
+
if (!trimmed)
|
|
246
|
+
return "";
|
|
247
|
+
if (!trimmed.includes("data:")) {
|
|
248
|
+
return extractTextFromJson(JSON.parse(trimmed));
|
|
249
|
+
}
|
|
250
|
+
const deltas = [];
|
|
251
|
+
let completedText = "";
|
|
252
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
253
|
+
if (!line.startsWith("data:"))
|
|
254
|
+
continue;
|
|
255
|
+
const payload = line.slice("data:".length).trim();
|
|
256
|
+
if (!payload || payload === "[DONE]")
|
|
257
|
+
continue;
|
|
258
|
+
const event = JSON.parse(payload);
|
|
259
|
+
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
|
|
260
|
+
deltas.push(event.delta);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (event.type === "response.completed" && event.response) {
|
|
264
|
+
completedText = extractTextFromJson(event.response);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return deltas.join("") || completedText;
|
|
268
|
+
}
|
|
269
|
+
function isRetryableStatus(status) {
|
|
270
|
+
return status === 429 || status >= 500;
|
|
271
|
+
}
|
|
272
|
+
function sleep(ms) {
|
|
273
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
274
|
+
}
|
|
275
|
+
async function parseJsonResponse(response, label) {
|
|
276
|
+
try {
|
|
277
|
+
return await response.json();
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
throw new Error(`${label} returned invalid JSON: ${err.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export async function loginCodexOauthApi(options = {}) {
|
|
284
|
+
const authPath = options.authPath ?? getCodexOauthApiAuthPath();
|
|
285
|
+
const issuer = (options.issuer ?? ISSUER).replace(/\/$/, "");
|
|
286
|
+
const tokenUrl = options.tokenUrl ?? `${issuer}/oauth/token`;
|
|
287
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
288
|
+
const maxWaitMs = options.maxWaitMs ?? 15 * 60 * 1000;
|
|
289
|
+
const deviceResponse = await fetchWithTimeout(`${issuer}/api/accounts/deviceauth/usercode`, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { "Content-Type": "application/json" },
|
|
292
|
+
body: JSON.stringify({ client_id: CODEX_CLIENT_ID }),
|
|
293
|
+
}, timeoutMs);
|
|
294
|
+
if (!deviceResponse.ok) {
|
|
295
|
+
throw new Error(`Codex device authorization failed (${deviceResponse.status}): ${await deviceResponse.text()}`);
|
|
296
|
+
}
|
|
297
|
+
const device = await parseJsonResponse(deviceResponse, "Codex device authorization");
|
|
298
|
+
const deviceAuthId = device.device_auth_id?.trim();
|
|
299
|
+
const userCode = device.user_code?.trim();
|
|
300
|
+
if (!deviceAuthId || !userCode) {
|
|
301
|
+
throw new Error("Codex device authorization response was missing device_auth_id or user_code");
|
|
302
|
+
}
|
|
303
|
+
const deviceUrl = `${issuer}/codex/device`;
|
|
304
|
+
await options.onDeviceCode?.({ url: deviceUrl, code: userCode });
|
|
305
|
+
const parsedInterval = typeof device.interval === "number" ? device.interval : Number.parseInt(String(device.interval ?? "5"), 10);
|
|
306
|
+
const pollIntervalMs = options.pollIntervalMs ?? Math.max(Number.isFinite(parsedInterval) ? parsedInterval : 5, 1) * 1000;
|
|
307
|
+
const startedAt = Date.now();
|
|
308
|
+
let authorizationCode = "";
|
|
309
|
+
let codeVerifier = "";
|
|
310
|
+
while (Date.now() - startedAt < maxWaitMs) {
|
|
311
|
+
const pollResponse = await fetchWithTimeout(`${issuer}/api/accounts/deviceauth/token`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: { "Content-Type": "application/json" },
|
|
314
|
+
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
|
|
315
|
+
}, timeoutMs);
|
|
316
|
+
if (pollResponse.ok) {
|
|
317
|
+
const poll = await parseJsonResponse(pollResponse, "Codex device authorization poll");
|
|
318
|
+
authorizationCode = poll.authorization_code?.trim() ?? "";
|
|
319
|
+
codeVerifier = poll.code_verifier?.trim() ?? "";
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
if (pollResponse.status !== 403 && pollResponse.status !== 404) {
|
|
323
|
+
throw new Error(`Codex device authorization poll failed (${pollResponse.status}): ${await pollResponse.text()}`);
|
|
324
|
+
}
|
|
325
|
+
await sleep(pollIntervalMs);
|
|
326
|
+
}
|
|
327
|
+
if (!authorizationCode || !codeVerifier) {
|
|
328
|
+
throw new Error("Codex device authorization timed out before sign-in completed");
|
|
329
|
+
}
|
|
330
|
+
const tokenResponse = await fetchWithTimeout(tokenUrl, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
333
|
+
body: new URLSearchParams({
|
|
334
|
+
grant_type: "authorization_code",
|
|
335
|
+
code: authorizationCode,
|
|
336
|
+
redirect_uri: `${issuer}/deviceauth/callback`,
|
|
337
|
+
client_id: CODEX_CLIENT_ID,
|
|
338
|
+
code_verifier: codeVerifier,
|
|
339
|
+
}).toString(),
|
|
340
|
+
}, timeoutMs);
|
|
341
|
+
if (!tokenResponse.ok) {
|
|
342
|
+
throw new Error(`Codex OAuth token exchange failed (${tokenResponse.status}): ${await tokenResponse.text()}`);
|
|
343
|
+
}
|
|
344
|
+
const tokens = await parseJsonResponse(tokenResponse, "Codex OAuth token exchange");
|
|
345
|
+
if (!tokens.access_token) {
|
|
346
|
+
throw new Error("Codex OAuth token exchange returned no access token");
|
|
347
|
+
}
|
|
348
|
+
const nextAuth = {
|
|
349
|
+
provider: "codex-oauth-api",
|
|
350
|
+
source: "device-code",
|
|
351
|
+
auth_mode: "chatgpt",
|
|
352
|
+
tokens: {
|
|
353
|
+
access_token: tokens.access_token,
|
|
354
|
+
refresh_token: tokens.refresh_token,
|
|
355
|
+
id_token: tokens.id_token,
|
|
356
|
+
},
|
|
357
|
+
last_refresh: new Date().toISOString(),
|
|
358
|
+
};
|
|
359
|
+
const accountId = extractAccountId(nextAuth.tokens);
|
|
360
|
+
if (accountId && nextAuth.tokens)
|
|
361
|
+
nextAuth.tokens.account_id = accountId;
|
|
362
|
+
await writeAuthFile(authPath, nextAuth);
|
|
363
|
+
const state = authStateFromFile(nextAuth, authPath);
|
|
364
|
+
if (!state)
|
|
365
|
+
throw new Error("Codex OAuth API login did not persist a usable access token");
|
|
366
|
+
return state;
|
|
367
|
+
}
|
|
368
|
+
export async function ensureCodexOauthApiAuth(options = {}) {
|
|
369
|
+
const authPath = options.authPath ?? getCodexOauthApiAuthPath();
|
|
370
|
+
try {
|
|
371
|
+
return await loadAuthState(authPath, options.codexCliAuthPath ?? getCodexCliAuthPath());
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return loginCodexOauthApi({ ...options, authPath });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export function createCodexOauthApiProvider(options = {}) {
|
|
378
|
+
const model = options.model?.trim() || DEFAULT_MODEL;
|
|
379
|
+
const responsesUrl = options.responsesUrl ?? RESPONSES_URL;
|
|
380
|
+
const authPath = options.authPath ?? getCodexOauthApiAuthPath();
|
|
381
|
+
const codexCliAuthPath = options.codexCliAuthPath ?? getCodexCliAuthPath();
|
|
382
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
383
|
+
return {
|
|
384
|
+
name: "codex-oauth-api",
|
|
385
|
+
run: async (prompt) => {
|
|
386
|
+
let authState = await loadAuthState(authPath, codexCliAuthPath);
|
|
387
|
+
let lastError;
|
|
388
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
389
|
+
const response = await fetchWithTimeout(responsesUrl, {
|
|
390
|
+
method: "POST",
|
|
391
|
+
headers: buildHeaders(authState),
|
|
392
|
+
body: buildBody(prompt, model),
|
|
393
|
+
}, timeoutMs);
|
|
394
|
+
if (response.status === 401 && attempt === 1) {
|
|
395
|
+
authState = await refreshAuth(authState, timeoutMs);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
const body = await response.text();
|
|
400
|
+
if (isRetryableStatus(response.status) && attempt < MAX_ATTEMPTS) {
|
|
401
|
+
lastError = new Error(`Codex OAuth API error (${response.status}): ${body}`);
|
|
402
|
+
await sleep(Math.pow(2, attempt - 1) * 1000);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
throw new Error(`Codex OAuth API error (${response.status}): ${body}`);
|
|
406
|
+
}
|
|
407
|
+
const text = parseResponseText(await response.text());
|
|
408
|
+
if (!text) {
|
|
409
|
+
throw new Error("Codex OAuth API returned no text content");
|
|
410
|
+
}
|
|
411
|
+
return text;
|
|
412
|
+
}
|
|
413
|
+
throw lastError ?? new Error("Codex OAuth API request failed after retries");
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
const DEFAULT_COMMAND = "codex";
|
|
3
|
+
const DEFAULT_MODEL = "gpt-5.5";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
5
|
+
export function createCodexOauthProvider(command = DEFAULT_COMMAND, model = DEFAULT_MODEL, options = {}) {
|
|
6
|
+
return {
|
|
7
|
+
name: "codex",
|
|
8
|
+
run: async (prompt) => {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let settled = false;
|
|
11
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
12
|
+
let timeout;
|
|
13
|
+
const finalize = (fn) => {
|
|
14
|
+
if (settled)
|
|
15
|
+
return;
|
|
16
|
+
settled = true;
|
|
17
|
+
if (timeout)
|
|
18
|
+
clearTimeout(timeout);
|
|
19
|
+
fn();
|
|
20
|
+
};
|
|
21
|
+
const args = [
|
|
22
|
+
"--ask-for-approval",
|
|
23
|
+
"never",
|
|
24
|
+
"exec",
|
|
25
|
+
"--ephemeral",
|
|
26
|
+
"--skip-git-repo-check",
|
|
27
|
+
"--sandbox",
|
|
28
|
+
"read-only",
|
|
29
|
+
"--color",
|
|
30
|
+
"never",
|
|
31
|
+
"-m",
|
|
32
|
+
model,
|
|
33
|
+
"-",
|
|
34
|
+
];
|
|
35
|
+
const proc = spawn(command, args, {
|
|
36
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
37
|
+
env: { ...process.env },
|
|
38
|
+
});
|
|
39
|
+
timeout = setTimeout(() => {
|
|
40
|
+
proc.kill("SIGTERM");
|
|
41
|
+
finalize(() => {
|
|
42
|
+
reject(new Error(`${command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds`));
|
|
43
|
+
});
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
let stdout = "";
|
|
46
|
+
let stderr = "";
|
|
47
|
+
proc.stdout.on("data", (data) => {
|
|
48
|
+
stdout += data.toString();
|
|
49
|
+
});
|
|
50
|
+
proc.stderr.on("data", (data) => {
|
|
51
|
+
stderr += data.toString();
|
|
52
|
+
});
|
|
53
|
+
proc.on("error", (err) => {
|
|
54
|
+
if (err.code === "ENOENT") {
|
|
55
|
+
finalize(() => reject(new Error(`${command} Codex CLI not found. Install Codex and sign in with ChatGPT using: codex login`)));
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
finalize(() => reject(err));
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
proc.on("close", (code) => {
|
|
62
|
+
if (code === 0) {
|
|
63
|
+
finalize(() => resolve(stdout.trim()));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const details = (stderr || stdout).trim();
|
|
67
|
+
finalize(() => reject(new Error(`${command} exited with code ${code}: ${details || "no output"}\n` +
|
|
68
|
+
"Codex OAuth mode uses your Codex CLI ChatGPT login. Run `codex login` and retry.")));
|
|
69
|
+
});
|
|
70
|
+
proc.stdin.write(prompt);
|
|
71
|
+
proc.stdin.end();
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|