@moxxy/cli 0.0.12 → 0.1.1
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 +278 -112
- package/bin/moxxy +10 -0
- package/package.json +36 -53
- package/src/api-client.js +286 -0
- package/src/cli.js +349 -0
- package/src/commands/agent.js +413 -0
- package/src/commands/auth.js +326 -0
- package/src/commands/channel.js +285 -0
- package/src/commands/doctor.js +261 -0
- package/src/commands/events.js +80 -0
- package/src/commands/gateway.js +428 -0
- package/src/commands/heartbeat.js +145 -0
- package/src/commands/init.js +954 -0
- package/src/commands/mcp.js +278 -0
- package/src/commands/plugin.js +583 -0
- package/src/commands/provider.js +1934 -0
- package/src/commands/settings.js +224 -0
- package/src/commands/skill.js +125 -0
- package/src/commands/template.js +237 -0
- package/src/commands/uninstall.js +196 -0
- package/src/commands/update.js +406 -0
- package/src/commands/vault.js +219 -0
- package/src/help.js +392 -0
- package/src/lib/plugin-registry.js +98 -0
- package/src/platform.js +40 -0
- package/src/sse-client.js +79 -0
- package/src/tui/action-wizards.js +130 -0
- package/src/tui/app.jsx +859 -0
- package/src/tui/components/action-picker.jsx +86 -0
- package/src/tui/components/chat-panel.jsx +120 -0
- package/src/tui/components/footer.jsx +13 -0
- package/src/tui/components/header.jsx +45 -0
- package/src/tui/components/input-area.jsx +384 -0
- package/src/tui/components/messages/ask-message.jsx +13 -0
- package/src/tui/components/messages/assistant-message.jsx +165 -0
- package/src/tui/components/messages/channel-message.jsx +18 -0
- package/src/tui/components/messages/event-message.jsx +22 -0
- package/src/tui/components/messages/hive-status.jsx +34 -0
- package/src/tui/components/messages/skill-message.jsx +31 -0
- package/src/tui/components/messages/system-message.jsx +12 -0
- package/src/tui/components/messages/thinking.jsx +25 -0
- package/src/tui/components/messages/tool-group.jsx +62 -0
- package/src/tui/components/messages/tool-message.jsx +66 -0
- package/src/tui/components/messages/user-message.jsx +12 -0
- package/src/tui/components/model-picker.jsx +138 -0
- package/src/tui/components/multiline-input.jsx +72 -0
- package/src/tui/events-handler.js +730 -0
- package/src/tui/helpers.js +59 -0
- package/src/tui/hooks/use-command-handler.js +451 -0
- package/src/tui/index.jsx +55 -0
- package/src/tui/input-utils.js +26 -0
- package/src/tui/markdown-renderer.js +66 -0
- package/src/tui/mcp-wizard.js +136 -0
- package/src/tui/model-picker.js +174 -0
- package/src/tui/slash-commands.js +26 -0
- package/src/tui/store.js +12 -0
- package/src/tui/theme.js +17 -0
- package/src/ui.js +109 -0
- package/bin/moxxy.js +0 -2
- package/dist/chunk-23LZYKQ6.mjs +0 -1131
- package/dist/chunk-2FZEA3NG.mjs +0 -457
- package/dist/chunk-3KDPLS22.mjs +0 -1131
- package/dist/chunk-3QRJTRBT.mjs +0 -1102
- package/dist/chunk-6DZX6EAA.mjs +0 -37
- package/dist/chunk-A4WRDUNY.mjs +0 -1242
- package/dist/chunk-C46NSEKG.mjs +0 -211
- package/dist/chunk-CAUXONEF.mjs +0 -1131
- package/dist/chunk-CPL5V56X.mjs +0 -1131
- package/dist/chunk-CTBVTTBG.mjs +0 -440
- package/dist/chunk-FHHLXTEZ.mjs +0 -1121
- package/dist/chunk-FXY3GPVA.mjs +0 -1126
- package/dist/chunk-GSNMMI3H.mjs +0 -530
- package/dist/chunk-HHOAOGUS.mjs +0 -1242
- package/dist/chunk-ITBO7BKI.mjs +0 -1243
- package/dist/chunk-J33O35WX.mjs +0 -532
- package/dist/chunk-N5JTPB6U.mjs +0 -820
- package/dist/chunk-NGVL4Q5C.mjs +0 -1102
- package/dist/chunk-Q2OCMNYI.mjs +0 -1131
- package/dist/chunk-QDVRLN6D.mjs +0 -1121
- package/dist/chunk-QO2JONHP.mjs +0 -1131
- package/dist/chunk-RVAPILHA.mjs +0 -1242
- package/dist/chunk-S7YBOV7E.mjs +0 -1131
- package/dist/chunk-SHIG6Y5L.mjs +0 -1074
- package/dist/chunk-SOFST2PV.mjs +0 -1242
- package/dist/chunk-SUNUYS6G.mjs +0 -1243
- package/dist/chunk-TMZWETMH.mjs +0 -1242
- package/dist/chunk-TYD7NMMI.mjs +0 -581
- package/dist/chunk-TYQ3YS42.mjs +0 -1068
- package/dist/chunk-UALWCJ7F.mjs +0 -1131
- package/dist/chunk-UQZKODNW.mjs +0 -1124
- package/dist/chunk-USC6R2ON.mjs +0 -1242
- package/dist/chunk-W32EQCVC.mjs +0 -823
- package/dist/chunk-WMB5ENMC.mjs +0 -1242
- package/dist/chunk-WNHA5JAP.mjs +0 -1242
- package/dist/cli-2AIWTL6F.mjs +0 -8
- package/dist/cli-2QKJ5UUL.mjs +0 -8
- package/dist/cli-4RIS6DQX.mjs +0 -8
- package/dist/cli-5RH4VBBL.mjs +0 -7
- package/dist/cli-7MK4YGOP.mjs +0 -7
- package/dist/cli-B4KH6MZI.mjs +0 -8
- package/dist/cli-CGO2LZ6Z.mjs +0 -8
- package/dist/cli-CVP26EL2.mjs +0 -8
- package/dist/cli-DDRVVNAV.mjs +0 -8
- package/dist/cli-E7U56QVQ.mjs +0 -8
- package/dist/cli-EQNRMLL3.mjs +0 -8
- package/dist/cli-F5RUHHH4.mjs +0 -8
- package/dist/cli-LX6FFSEF.mjs +0 -8
- package/dist/cli-LY74GWKR.mjs +0 -6
- package/dist/cli-MAT3ZJHI.mjs +0 -8
- package/dist/cli-NJXXTQYF.mjs +0 -8
- package/dist/cli-O4ZGFAZG.mjs +0 -8
- package/dist/cli-ORVLI3UQ.mjs +0 -8
- package/dist/cli-PV43ZVKA.mjs +0 -8
- package/dist/cli-REVD6ISM.mjs +0 -8
- package/dist/cli-TBX76KQX.mjs +0 -8
- package/dist/cli-THCGF7SQ.mjs +0 -8
- package/dist/cli-TLX5ENVM.mjs +0 -8
- package/dist/cli-TMNI5ZYE.mjs +0 -8
- package/dist/cli-TNJHCBQA.mjs +0 -6
- package/dist/cli-TUX22CZP.mjs +0 -8
- package/dist/cli-XJVH7EEP.mjs +0 -8
- package/dist/cli-XXOW4VXJ.mjs +0 -8
- package/dist/cli-XZ5RESNB.mjs +0 -6
- package/dist/cli-YCBYZ76Q.mjs +0 -8
- package/dist/cli-ZLMQCU7X.mjs +0 -8
- package/dist/dist-2VGKJRBH.mjs +0 -6820
- package/dist/dist-37BNX4QG.mjs +0 -7081
- package/dist/dist-7LTHRYKA.mjs +0 -11569
- package/dist/dist-7XJPQW5C.mjs +0 -6950
- package/dist/dist-AYMVOW7T.mjs +0 -7123
- package/dist/dist-BHUWCDRS.mjs +0 -7132
- package/dist/dist-FAXRJMEN.mjs +0 -6812
- package/dist/dist-HQGANM3P.mjs +0 -6976
- package/dist/dist-KATLOZQV.mjs +0 -7054
- package/dist/dist-KLSB6YHV.mjs +0 -6964
- package/dist/dist-LKIOZQ42.mjs +0 -17
- package/dist/dist-UYA4RJUH.mjs +0 -2792
- package/dist/dist-ZYHCBILM.mjs +0 -6993
- package/dist/index.d.mts +0 -23
- package/dist/index.d.ts +0 -23
- package/dist/index.js +0 -25531
- package/dist/index.mjs +0 -18
- package/dist/src-APP5P3UD.mjs +0 -1386
- package/dist/src-D5HMDDVE.mjs +0 -1324
- package/dist/src-EK3WD4AU.mjs +0 -1327
- package/dist/src-LSZFLMFN.mjs +0 -1400
- package/dist/src-T77DFTFP.mjs +0 -1407
- package/dist/src-WIOCZRAC.mjs +0 -1397
- package/dist/src-YK6CHCMW.mjs +0 -1400
|
@@ -0,0 +1,1934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider commands: install/list/verify.
|
|
3
|
+
* Includes built-in provider catalog with frontier models.
|
|
4
|
+
*/
|
|
5
|
+
import { parseFlags } from './auth.js';
|
|
6
|
+
import { isInteractive, handleCancel, withSpinner, showResult, p } from '../ui.js';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
|
|
11
|
+
const OPENAI_API_BASE = 'https://api.openai.com/v1';
|
|
12
|
+
export const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
|
|
13
|
+
const OPENAI_CODEX_DISPLAY_NAME = 'OpenAI (Codex OAuth)';
|
|
14
|
+
const OPENAI_CODEX_ISSUER = 'https://auth.openai.com';
|
|
15
|
+
const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
16
|
+
const OPENAI_CODEX_DEVICE_CODE_ENDPOINT = `${OPENAI_CODEX_ISSUER}/api/accounts/deviceauth/usercode`;
|
|
17
|
+
const OPENAI_CODEX_DEVICE_TOKEN_ENDPOINT = `${OPENAI_CODEX_ISSUER}/api/accounts/deviceauth/token`;
|
|
18
|
+
const OPENAI_CODEX_TOKEN_ENDPOINT = `${OPENAI_CODEX_ISSUER}/oauth/token`;
|
|
19
|
+
const OPENAI_CODEX_AUTHORIZE_ENDPOINT = `${OPENAI_CODEX_ISSUER}/oauth/authorize`;
|
|
20
|
+
const OPENAI_CODEX_DEVICE_VERIFY_URL = `${OPENAI_CODEX_ISSUER}/codex/device`;
|
|
21
|
+
const OPENAI_CODEX_DEVICE_CALLBACK_REDIRECT_URI = `${OPENAI_CODEX_ISSUER}/deviceauth/callback`;
|
|
22
|
+
const OPENAI_CODEX_BROWSER_CALLBACK_PATH = '/auth/callback';
|
|
23
|
+
const OPENAI_CODEX_BROWSER_CALLBACK_PORT = 1455;
|
|
24
|
+
const OPENAI_CODEX_SCOPE = 'openid profile email offline_access';
|
|
25
|
+
const OPENAI_CODEX_ORIGINATOR = 'Codex Desktop';
|
|
26
|
+
const OPENAI_CODEX_SECRET_KEY_NAME = 'OPENAI_CODEX_API_KEY';
|
|
27
|
+
const OPENAI_CODEX_BACKEND_KEY = `moxxy_provider_${OPENAI_CODEX_PROVIDER_ID}`;
|
|
28
|
+
const OPENAI_CODEX_CHATGPT_API_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
29
|
+
const OPENAI_CODEX_OAUTH_SESSION_MODE = 'chatgpt_oauth_session';
|
|
30
|
+
|
|
31
|
+
export const ANTHROPIC_PROVIDER_ID = 'anthropic';
|
|
32
|
+
const ANTHROPIC_SECRET_KEY_NAME = 'ANTHROPIC_API_KEY';
|
|
33
|
+
const ANTHROPIC_BACKEND_KEY = `moxxy_provider_${ANTHROPIC_PROVIDER_ID}`;
|
|
34
|
+
const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
|
|
35
|
+
const ANTHROPIC_OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
|
|
36
|
+
const ANTHROPIC_OAUTH_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
|
|
37
|
+
const ANTHROPIC_OAUTH_REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback';
|
|
38
|
+
const ANTHROPIC_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
39
|
+
const ANTHROPIC_OAUTH_SCOPE = 'org:create_api_key user:profile user:inference';
|
|
40
|
+
const ANTHROPIC_OAUTH_SESSION_MODE = 'anthropic_oauth_session';
|
|
41
|
+
const OLLAMA_PROVIDER_ID = 'ollama';
|
|
42
|
+
const OLLAMA_API_BASE = 'http://127.0.0.1:11434/v1';
|
|
43
|
+
const OPENAI_CODEX_MODEL_IDS = [
|
|
44
|
+
'gpt-5.3-codex',
|
|
45
|
+
'gpt-5.2-codex',
|
|
46
|
+
'gpt-5.1-codex',
|
|
47
|
+
'gpt-5.1-codex-mini',
|
|
48
|
+
'gpt-5.1-codex-max',
|
|
49
|
+
'gpt-5.2',
|
|
50
|
+
'gpt-4.1',
|
|
51
|
+
'gpt-4.1-mini',
|
|
52
|
+
'gpt-4.1-nano',
|
|
53
|
+
'o3',
|
|
54
|
+
'o4-mini',
|
|
55
|
+
'gpt-4o',
|
|
56
|
+
'gpt-4o-mini',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function buildCodexDeviceCodeBody(clientId, opts = {}) {
|
|
60
|
+
const allowedWorkspaceId = String(opts.allowedWorkspaceId || '').trim();
|
|
61
|
+
const organizationId = String(opts.organizationId || '').trim();
|
|
62
|
+
const projectId = String(opts.projectId || '').trim();
|
|
63
|
+
|
|
64
|
+
const body = {
|
|
65
|
+
client_id: clientId,
|
|
66
|
+
};
|
|
67
|
+
if (allowedWorkspaceId) body.allowed_workspace_id = allowedWorkspaceId;
|
|
68
|
+
if (organizationId) body.organization_id = organizationId;
|
|
69
|
+
if (projectId) body.project_id = projectId;
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildCodexAuthorizationCodeExchangeBody({
|
|
74
|
+
code,
|
|
75
|
+
redirectUri,
|
|
76
|
+
clientId,
|
|
77
|
+
codeVerifier,
|
|
78
|
+
}) {
|
|
79
|
+
return new URLSearchParams({
|
|
80
|
+
grant_type: 'authorization_code',
|
|
81
|
+
code,
|
|
82
|
+
redirect_uri: redirectUri,
|
|
83
|
+
client_id: clientId,
|
|
84
|
+
code_verifier: codeVerifier,
|
|
85
|
+
}).toString();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildCodexApiKeyExchangeBody({ clientId, idToken }) {
|
|
89
|
+
return new URLSearchParams({
|
|
90
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
91
|
+
client_id: clientId,
|
|
92
|
+
requested_token: 'openai-api-key',
|
|
93
|
+
subject_token: idToken,
|
|
94
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
|
|
95
|
+
}).toString();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildPkceCodeChallenge(codeVerifier) {
|
|
99
|
+
return createHash('sha256').update(codeVerifier).digest('base64url');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildCodexBrowserAuthorizeUrl({
|
|
103
|
+
clientId,
|
|
104
|
+
redirectUri,
|
|
105
|
+
codeChallenge,
|
|
106
|
+
state,
|
|
107
|
+
originator = OPENAI_CODEX_ORIGINATOR,
|
|
108
|
+
allowedWorkspaceId,
|
|
109
|
+
organizationId,
|
|
110
|
+
projectId,
|
|
111
|
+
}) {
|
|
112
|
+
const url = new URL(OPENAI_CODEX_AUTHORIZE_ENDPOINT);
|
|
113
|
+
url.searchParams.set('response_type', 'code');
|
|
114
|
+
url.searchParams.set('client_id', clientId);
|
|
115
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
116
|
+
url.searchParams.set('scope', OPENAI_CODEX_SCOPE);
|
|
117
|
+
url.searchParams.set('code_challenge', codeChallenge);
|
|
118
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
119
|
+
url.searchParams.set('id_token_add_organizations', 'true');
|
|
120
|
+
url.searchParams.set('codex_cli_simplified_flow', 'true');
|
|
121
|
+
url.searchParams.set('state', state);
|
|
122
|
+
url.searchParams.set('originator', originator);
|
|
123
|
+
if (allowedWorkspaceId) {
|
|
124
|
+
url.searchParams.set('allowed_workspace_id', allowedWorkspaceId);
|
|
125
|
+
}
|
|
126
|
+
if (organizationId) {
|
|
127
|
+
url.searchParams.set('organization_id', organizationId);
|
|
128
|
+
}
|
|
129
|
+
if (projectId) {
|
|
130
|
+
url.searchParams.set('project_id', projectId);
|
|
131
|
+
}
|
|
132
|
+
return url.toString();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseJsonSafe(text) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(text);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toInlineText(value) {
|
|
144
|
+
if (typeof value !== 'string') return '';
|
|
145
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatOpenAiOAuthError(prefix, status, payload, rawText = '') {
|
|
149
|
+
const nestedError = payload?.error && typeof payload.error === 'object' ? payload.error : null;
|
|
150
|
+
const code = toInlineText(
|
|
151
|
+
nestedError?.code ||
|
|
152
|
+
payload?.code ||
|
|
153
|
+
(typeof payload?.error === 'string' ? payload.error : '')
|
|
154
|
+
);
|
|
155
|
+
const description = toInlineText(
|
|
156
|
+
nestedError?.message ||
|
|
157
|
+
nestedError?.error_description ||
|
|
158
|
+
payload?.error_description ||
|
|
159
|
+
payload?.message ||
|
|
160
|
+
payload?.detail ||
|
|
161
|
+
payload?.error_summary ||
|
|
162
|
+
''
|
|
163
|
+
);
|
|
164
|
+
const fallbackText = toInlineText(rawText);
|
|
165
|
+
|
|
166
|
+
const parts = [];
|
|
167
|
+
if (code) parts.push(code);
|
|
168
|
+
if (description) parts.push(description);
|
|
169
|
+
if (!code && !description && fallbackText) {
|
|
170
|
+
parts.push(fallbackText.slice(0, 220));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let message = `${prefix} (${status})`;
|
|
174
|
+
if (parts.length > 0) {
|
|
175
|
+
message += `: ${parts.join(' - ')}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (prefix === 'OpenAI API key token-exchange failed' && status === 401) {
|
|
179
|
+
const missingOrg = code === 'invalid_subject_token' || description.includes('organization_id');
|
|
180
|
+
if (missingOrg) {
|
|
181
|
+
message += '. Retry OAuth with organization-scoped login (interactive flow will prompt to select organization). If OpenCode works but this step fails, it usually means OpenCode is using ChatGPT backend tokens while Moxxy requires API-key issuance linked to an API organization.';
|
|
182
|
+
} else if (!description) {
|
|
183
|
+
message += '. The OpenAI account may not be eligible for OAuth API-key issuance yet (API org/project or billing setup may be missing).';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return message;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toBool(v) {
|
|
191
|
+
return v === true || v === 'true';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseJwtPayload(token) {
|
|
195
|
+
if (typeof token !== 'string') return null;
|
|
196
|
+
const parts = token.split('.');
|
|
197
|
+
if (parts.length < 2) return null;
|
|
198
|
+
|
|
199
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
200
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const json = Buffer.from(padded, 'base64').toString('utf8');
|
|
204
|
+
return JSON.parse(json);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function extractOpenAiAuthClaims(idToken) {
|
|
211
|
+
const payload = parseJwtPayload(idToken);
|
|
212
|
+
const claims = payload?.['https://api.openai.com/auth'];
|
|
213
|
+
if (claims && typeof claims === 'object') {
|
|
214
|
+
return claims;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function extractCodexAccountIdFromClaims(claims) {
|
|
220
|
+
if (!claims || typeof claims !== 'object') return '';
|
|
221
|
+
const rootAccount = typeof claims.chatgpt_account_id === 'string' ? claims.chatgpt_account_id.trim() : '';
|
|
222
|
+
if (rootAccount) return rootAccount;
|
|
223
|
+
|
|
224
|
+
const apiAuth = claims['https://api.openai.com/auth'];
|
|
225
|
+
const apiAuthAccount = typeof apiAuth?.chatgpt_account_id === 'string' ? apiAuth.chatgpt_account_id.trim() : '';
|
|
226
|
+
if (apiAuthAccount) return apiAuthAccount;
|
|
227
|
+
|
|
228
|
+
const firstOrgId = typeof apiAuth?.organizations?.[0]?.id === 'string' ? apiAuth.organizations[0].id.trim() : '';
|
|
229
|
+
if (firstOrgId) return firstOrgId;
|
|
230
|
+
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function extractCodexAccountIdFromTokens(tokens) {
|
|
235
|
+
const fromIdToken = extractCodexAccountIdFromClaims(parseJwtPayload(tokens?.id_token));
|
|
236
|
+
if (fromIdToken) return fromIdToken;
|
|
237
|
+
return extractCodexAccountIdFromClaims(parseJwtPayload(tokens?.access_token));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function toPositiveInt(value, fallback = 0) {
|
|
241
|
+
const n = Number(value);
|
|
242
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
243
|
+
return Math.trunc(n);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function buildOpenAiCodexSessionSecret({
|
|
247
|
+
accessToken,
|
|
248
|
+
refreshToken,
|
|
249
|
+
expiresAtMs,
|
|
250
|
+
accountId,
|
|
251
|
+
}) {
|
|
252
|
+
const out = {
|
|
253
|
+
mode: OPENAI_CODEX_OAUTH_SESSION_MODE,
|
|
254
|
+
issuer: OPENAI_CODEX_ISSUER,
|
|
255
|
+
client_id: OPENAI_CODEX_CLIENT_ID,
|
|
256
|
+
access_token: String(accessToken || '').trim(),
|
|
257
|
+
refresh_token: String(refreshToken || '').trim(),
|
|
258
|
+
expires_at: toPositiveInt(expiresAtMs, 0),
|
|
259
|
+
};
|
|
260
|
+
const account = String(accountId || '').trim();
|
|
261
|
+
if (account) {
|
|
262
|
+
out.account_id = account;
|
|
263
|
+
}
|
|
264
|
+
return JSON.stringify(out);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function buildOpenAiCodexSessionModels(accountId = '') {
|
|
268
|
+
const account = String(accountId || '').trim();
|
|
269
|
+
return OPENAI_CODEX_MODEL_IDS.map(modelId => ({
|
|
270
|
+
model_id: modelId,
|
|
271
|
+
display_name: modelId,
|
|
272
|
+
metadata: {
|
|
273
|
+
api_base: OPENAI_CODEX_CHATGPT_API_BASE,
|
|
274
|
+
...(account ? { chatgpt_account_id: account } : {}),
|
|
275
|
+
},
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function listOrganizationCandidates(apiAuthClaims) {
|
|
280
|
+
const orgs = Array.isArray(apiAuthClaims?.organizations) ? apiAuthClaims.organizations : [];
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const org of orgs) {
|
|
283
|
+
const id = typeof org?.id === 'string' ? org.id.trim() : '';
|
|
284
|
+
if (!id) continue;
|
|
285
|
+
out.push({
|
|
286
|
+
id,
|
|
287
|
+
title: typeof org?.title === 'string' ? org.title.trim() : '',
|
|
288
|
+
is_default: org?.is_default === true,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isMissingOrganizationIdError(err) {
|
|
295
|
+
const msg = String(err?.message || '');
|
|
296
|
+
return msg.includes('invalid_subject_token') && msg.includes('organization_id');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toInt(value, fallback) {
|
|
300
|
+
const n = Number(value);
|
|
301
|
+
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function resolveOpenAiAuthMethod(flags) {
|
|
305
|
+
const raw = String(flags.method || flags.auth_method || '').trim().toLowerCase();
|
|
306
|
+
if (raw === 'browser' || raw === 'headless') {
|
|
307
|
+
return raw;
|
|
308
|
+
}
|
|
309
|
+
if (toBool(flags.browser)) {
|
|
310
|
+
return 'browser';
|
|
311
|
+
}
|
|
312
|
+
if (toBool(flags.headless)) {
|
|
313
|
+
return 'headless';
|
|
314
|
+
}
|
|
315
|
+
if (toBool(flags.no_browser) || toBool(flags.noBrowser)) {
|
|
316
|
+
return 'headless';
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function buildOrgScopeFromFlags(flags) {
|
|
322
|
+
return {
|
|
323
|
+
allowedWorkspaceId: String(flags.allowed_workspace_id || flags.allowedWorkspaceId || '').trim() || '',
|
|
324
|
+
organizationId: String(flags.organization_id || flags.organizationId || '').trim() || '',
|
|
325
|
+
projectId: String(flags.project_id || flags.projectId || '').trim() || '',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function buildScopedRetryFlags(flags, selectedOrg, method) {
|
|
330
|
+
return {
|
|
331
|
+
...flags,
|
|
332
|
+
method,
|
|
333
|
+
allowed_workspace_id: selectedOrg,
|
|
334
|
+
organization_id: selectedOrg,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function sleep(ms) {
|
|
339
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function tryOpenUrl(url) {
|
|
343
|
+
let cmd;
|
|
344
|
+
let args;
|
|
345
|
+
|
|
346
|
+
if (process.platform === 'darwin') {
|
|
347
|
+
cmd = 'open';
|
|
348
|
+
args = [url];
|
|
349
|
+
} else if (process.platform === 'win32') {
|
|
350
|
+
cmd = 'cmd';
|
|
351
|
+
args = ['/c', 'start', '', url];
|
|
352
|
+
} else {
|
|
353
|
+
cmd = 'xdg-open';
|
|
354
|
+
args = [url];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
359
|
+
child.unref();
|
|
360
|
+
return true;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function createCodeVerifier() {
|
|
367
|
+
return randomBytes(48).toString('base64url');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function createStateToken() {
|
|
371
|
+
return randomBytes(24).toString('base64url');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function respondHtml(res, statusCode, bodyHtml) {
|
|
375
|
+
res.statusCode = statusCode;
|
|
376
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
377
|
+
res.end(bodyHtml);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function createAuthCallbackHtml(success, message) {
|
|
381
|
+
const title = success ? 'Authorization Complete' : 'Authorization Failed';
|
|
382
|
+
const escaped = String(message || '')
|
|
383
|
+
.replace(/&/g, '&')
|
|
384
|
+
.replace(/</g, '<')
|
|
385
|
+
.replace(/>/g, '>');
|
|
386
|
+
|
|
387
|
+
const accentColor = success ? '#10b981' : '#ef4444';
|
|
388
|
+
const icon = success
|
|
389
|
+
? `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${accentColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`
|
|
390
|
+
: `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="${accentColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
|
|
391
|
+
|
|
392
|
+
return `<!doctype html>
|
|
393
|
+
<html lang="en">
|
|
394
|
+
<head>
|
|
395
|
+
<meta charset="utf-8">
|
|
396
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
397
|
+
<title>${title} — Moxxy</title>
|
|
398
|
+
<style>
|
|
399
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
400
|
+
body {
|
|
401
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
402
|
+
background: #0a0a0a;
|
|
403
|
+
color: #e5e5e5;
|
|
404
|
+
display: flex;
|
|
405
|
+
align-items: center;
|
|
406
|
+
justify-content: center;
|
|
407
|
+
min-height: 100vh;
|
|
408
|
+
padding: 1rem;
|
|
409
|
+
}
|
|
410
|
+
.card {
|
|
411
|
+
background: #171717;
|
|
412
|
+
border: 1px solid #262626;
|
|
413
|
+
border-radius: 16px;
|
|
414
|
+
padding: 3rem 2.5rem;
|
|
415
|
+
max-width: 420px;
|
|
416
|
+
width: 100%;
|
|
417
|
+
text-align: center;
|
|
418
|
+
animation: fadeIn 0.4s ease-out;
|
|
419
|
+
}
|
|
420
|
+
@keyframes fadeIn {
|
|
421
|
+
from { opacity: 0; transform: translateY(12px); }
|
|
422
|
+
to { opacity: 1; transform: translateY(0); }
|
|
423
|
+
}
|
|
424
|
+
.icon {
|
|
425
|
+
display: inline-flex;
|
|
426
|
+
align-items: center;
|
|
427
|
+
justify-content: center;
|
|
428
|
+
width: 72px;
|
|
429
|
+
height: 72px;
|
|
430
|
+
border-radius: 50%;
|
|
431
|
+
background: ${accentColor}1a;
|
|
432
|
+
margin-bottom: 1.5rem;
|
|
433
|
+
}
|
|
434
|
+
h1 {
|
|
435
|
+
font-size: 1.25rem;
|
|
436
|
+
font-weight: 600;
|
|
437
|
+
color: #fafafa;
|
|
438
|
+
margin-bottom: 0.5rem;
|
|
439
|
+
}
|
|
440
|
+
.message {
|
|
441
|
+
font-size: 0.9rem;
|
|
442
|
+
color: #a3a3a3;
|
|
443
|
+
line-height: 1.5;
|
|
444
|
+
margin-bottom: 1.75rem;
|
|
445
|
+
}
|
|
446
|
+
.hint {
|
|
447
|
+
font-size: 0.8rem;
|
|
448
|
+
color: #525252;
|
|
449
|
+
}
|
|
450
|
+
.brand {
|
|
451
|
+
margin-top: 2rem;
|
|
452
|
+
font-size: 0.75rem;
|
|
453
|
+
color: #404040;
|
|
454
|
+
letter-spacing: 0.05em;
|
|
455
|
+
text-transform: uppercase;
|
|
456
|
+
}
|
|
457
|
+
</style>
|
|
458
|
+
</head>
|
|
459
|
+
<body>
|
|
460
|
+
<div class="card">
|
|
461
|
+
<div class="icon">${icon}</div>
|
|
462
|
+
<h1>${title}</h1>
|
|
463
|
+
<p class="message">${escaped}</p>
|
|
464
|
+
<p class="hint">${success ? 'This window will close automatically.' : 'Please try again from the terminal.'}</p>
|
|
465
|
+
<p class="brand">moxxy</p>
|
|
466
|
+
</div>
|
|
467
|
+
${success ? '<script>setTimeout(()=>window.close(),2000)</script>' : ''}
|
|
468
|
+
</body>
|
|
469
|
+
</html>`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function bindServer(server, port, host) {
|
|
473
|
+
return await new Promise((resolve, reject) => {
|
|
474
|
+
const onError = (err) => {
|
|
475
|
+
server.off('listening', onListening);
|
|
476
|
+
reject(err);
|
|
477
|
+
};
|
|
478
|
+
const onListening = () => {
|
|
479
|
+
server.off('error', onError);
|
|
480
|
+
resolve();
|
|
481
|
+
};
|
|
482
|
+
server.once('error', onError);
|
|
483
|
+
server.once('listening', onListening);
|
|
484
|
+
server.listen(port, host);
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function startBrowserOAuthCallbackServer({ expectedState, timeoutMs, preferredPort }) {
|
|
489
|
+
const host = '127.0.0.1';
|
|
490
|
+
let finish;
|
|
491
|
+
let fail;
|
|
492
|
+
let isDone = false;
|
|
493
|
+
let timeoutId;
|
|
494
|
+
|
|
495
|
+
const done = (fn, value) => {
|
|
496
|
+
if (isDone) return;
|
|
497
|
+
isDone = true;
|
|
498
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
499
|
+
fn(value);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const server = createServer((req, res) => {
|
|
503
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
504
|
+
if (url.pathname !== OPENAI_CODEX_BROWSER_CALLBACK_PATH) {
|
|
505
|
+
res.statusCode = 404;
|
|
506
|
+
res.end('Not found');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const returnedState = url.searchParams.get('state') || '';
|
|
511
|
+
const code = url.searchParams.get('code') || '';
|
|
512
|
+
const error = url.searchParams.get('error') || '';
|
|
513
|
+
const errorDescription = url.searchParams.get('error_description') || '';
|
|
514
|
+
|
|
515
|
+
if (returnedState !== expectedState) {
|
|
516
|
+
respondHtml(res, 400, createAuthCallbackHtml(false, 'Invalid OAuth state'));
|
|
517
|
+
done(fail, new Error('OpenAI browser OAuth callback rejected: state mismatch'));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (error) {
|
|
522
|
+
const msg = [error, errorDescription].filter(Boolean).join(' - ');
|
|
523
|
+
respondHtml(res, 400, createAuthCallbackHtml(false, msg || 'Authorization failed'));
|
|
524
|
+
done(fail, new Error(`OpenAI browser OAuth callback failed: ${msg || error}`));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!code) {
|
|
529
|
+
respondHtml(res, 400, createAuthCallbackHtml(false, 'Missing authorization code'));
|
|
530
|
+
done(fail, new Error('OpenAI browser OAuth callback missing authorization code'));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
respondHtml(res, 200, createAuthCallbackHtml(true, 'You can return to Moxxy.'));
|
|
535
|
+
done(finish, { authorization_code: code });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
await bindServer(server, preferredPort, host);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
if (err?.code !== 'EADDRINUSE') {
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
await bindServer(server, 0, host);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const addr = server.address();
|
|
548
|
+
if (!addr || typeof addr === 'string') {
|
|
549
|
+
server.close();
|
|
550
|
+
throw new Error('Could not determine local OAuth callback port');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
554
|
+
finish = resolve;
|
|
555
|
+
fail = reject;
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
timeoutId = setTimeout(() => {
|
|
559
|
+
done(fail, new Error('Timed out waiting for browser authorization callback'));
|
|
560
|
+
}, Math.max(15_000, timeoutMs));
|
|
561
|
+
|
|
562
|
+
const close = async () => {
|
|
563
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
redirectUri: `http://localhost:${addr.port}${OPENAI_CODEX_BROWSER_CALLBACK_PATH}`,
|
|
568
|
+
waitForAuthorizationCode: () => resultPromise,
|
|
569
|
+
close,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function requestCodexDeviceCode(opts = {}) {
|
|
574
|
+
const resp = await fetch(OPENAI_CODEX_DEVICE_CODE_ENDPOINT, {
|
|
575
|
+
method: 'POST',
|
|
576
|
+
headers: { 'content-type': 'application/json' },
|
|
577
|
+
body: JSON.stringify(buildCodexDeviceCodeBody(OPENAI_CODEX_CLIENT_ID, opts)),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const text = await resp.text();
|
|
581
|
+
const json = parseJsonSafe(text);
|
|
582
|
+
if (!resp.ok || !json?.device_auth_id || !json?.user_code) {
|
|
583
|
+
throw new Error(formatOpenAiOAuthError('OpenAI OAuth device-code request failed', resp.status, json, text));
|
|
584
|
+
}
|
|
585
|
+
return json;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function pollCodexAuthorizationCode(deviceAuthId, userCode, intervalSeconds, expiresInSeconds) {
|
|
589
|
+
const deadline = Date.now() + (Math.max(30, expiresInSeconds) * 1000);
|
|
590
|
+
let pollIntervalMs = Math.max(1, intervalSeconds) * 1000;
|
|
591
|
+
|
|
592
|
+
while (Date.now() < deadline) {
|
|
593
|
+
await sleep(pollIntervalMs);
|
|
594
|
+
|
|
595
|
+
const resp = await fetch(OPENAI_CODEX_DEVICE_TOKEN_ENDPOINT, {
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers: { 'content-type': 'application/json' },
|
|
598
|
+
body: JSON.stringify({
|
|
599
|
+
device_auth_id: deviceAuthId,
|
|
600
|
+
user_code: userCode,
|
|
601
|
+
}),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const text = await resp.text();
|
|
605
|
+
const json = parseJsonSafe(text) || {};
|
|
606
|
+
if (resp.ok && json.authorization_code && json.code_verifier) {
|
|
607
|
+
return json;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const code = json.error;
|
|
611
|
+
if (code === 'authorization_pending' || resp.status === 403 || resp.status === 404) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (code === 'slow_down') {
|
|
615
|
+
pollIntervalMs += 5000;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (code === 'expired_token') {
|
|
619
|
+
throw new Error('OpenAI OAuth session expired before authorization was completed');
|
|
620
|
+
}
|
|
621
|
+
if (code === 'access_denied') {
|
|
622
|
+
throw new Error('OpenAI OAuth authorization was denied');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
throw new Error(formatOpenAiOAuthError('OpenAI OAuth device token poll failed', resp.status, json, text));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
throw new Error('Timed out waiting for OpenAI OAuth authorization');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function exchangeCodexIdToken({
|
|
632
|
+
authorizationCode,
|
|
633
|
+
codeVerifier,
|
|
634
|
+
redirectUri = OPENAI_CODEX_DEVICE_CALLBACK_REDIRECT_URI,
|
|
635
|
+
}) {
|
|
636
|
+
const resp = await fetch(OPENAI_CODEX_TOKEN_ENDPOINT, {
|
|
637
|
+
method: 'POST',
|
|
638
|
+
headers: {
|
|
639
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
640
|
+
},
|
|
641
|
+
body: buildCodexAuthorizationCodeExchangeBody({
|
|
642
|
+
code: authorizationCode,
|
|
643
|
+
redirectUri,
|
|
644
|
+
clientId: OPENAI_CODEX_CLIENT_ID,
|
|
645
|
+
codeVerifier,
|
|
646
|
+
}),
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const text = await resp.text();
|
|
650
|
+
const json = parseJsonSafe(text);
|
|
651
|
+
if (!resp.ok || !json?.id_token) {
|
|
652
|
+
throw new Error(formatOpenAiOAuthError('OpenAI OAuth code exchange failed', resp.status, json, text));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return json;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function exchangeCodexApiKey(idToken) {
|
|
659
|
+
const resp = await fetch(OPENAI_CODEX_TOKEN_ENDPOINT, {
|
|
660
|
+
method: 'POST',
|
|
661
|
+
headers: {
|
|
662
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
663
|
+
},
|
|
664
|
+
body: buildCodexApiKeyExchangeBody({
|
|
665
|
+
clientId: OPENAI_CODEX_CLIENT_ID,
|
|
666
|
+
idToken,
|
|
667
|
+
}),
|
|
668
|
+
});
|
|
669
|
+
const text = await resp.text();
|
|
670
|
+
const json = parseJsonSafe(text);
|
|
671
|
+
if (!resp.ok || !json?.access_token) {
|
|
672
|
+
throw new Error(formatOpenAiOAuthError('OpenAI API key token-exchange failed', resp.status, json, text));
|
|
673
|
+
}
|
|
674
|
+
return json.access_token;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function parseOpenAiModels(payload) {
|
|
678
|
+
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
|
679
|
+
const unique = new Set();
|
|
680
|
+
for (const row of rows) {
|
|
681
|
+
const id = typeof row?.id === 'string' ? row.id.trim() : '';
|
|
682
|
+
if (id) unique.add(id);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return Array.from(unique)
|
|
686
|
+
.sort((a, b) => a.localeCompare(b))
|
|
687
|
+
.map(id => ({
|
|
688
|
+
model_id: id,
|
|
689
|
+
display_name: id,
|
|
690
|
+
metadata: { api_base: OPENAI_API_BASE },
|
|
691
|
+
}));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function fetchOpenAiModels(apiKey) {
|
|
695
|
+
const resp = await fetch(`${OPENAI_API_BASE}/models`, {
|
|
696
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
697
|
+
});
|
|
698
|
+
const text = await resp.text();
|
|
699
|
+
const json = parseJsonSafe(text);
|
|
700
|
+
if (!resp.ok || !json) {
|
|
701
|
+
throw new Error(`Fetching OpenAI models failed (${resp.status})`);
|
|
702
|
+
}
|
|
703
|
+
const models = parseOpenAiModels(json);
|
|
704
|
+
if (models.length === 0) {
|
|
705
|
+
throw new Error('OpenAI model list is empty');
|
|
706
|
+
}
|
|
707
|
+
return models;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function normalizeOllamaApiBase(apiBase = OLLAMA_API_BASE) {
|
|
711
|
+
const trimmed = String(apiBase || OLLAMA_API_BASE).trim().replace(/\/+$/, '');
|
|
712
|
+
if (!trimmed) return OLLAMA_API_BASE;
|
|
713
|
+
if (trimmed.endsWith('/chat/completions')) {
|
|
714
|
+
return trimmed.slice(0, -'/chat/completions'.length);
|
|
715
|
+
}
|
|
716
|
+
if (trimmed.endsWith('/models')) {
|
|
717
|
+
return trimmed.slice(0, -'/models'.length);
|
|
718
|
+
}
|
|
719
|
+
return trimmed;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function alternateOllamaApiBase(apiBase = OLLAMA_API_BASE) {
|
|
723
|
+
try {
|
|
724
|
+
const url = new URL(normalizeOllamaApiBase(apiBase));
|
|
725
|
+
if (url.hostname === 'localhost') {
|
|
726
|
+
url.hostname = '127.0.0.1';
|
|
727
|
+
return url.toString().replace(/\/+$/, '');
|
|
728
|
+
}
|
|
729
|
+
if (url.hostname === '127.0.0.1') {
|
|
730
|
+
url.hostname = 'localhost';
|
|
731
|
+
return url.toString().replace(/\/+$/, '');
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function buildOllamaDiscoveryUrls(apiBase = OLLAMA_API_BASE) {
|
|
740
|
+
const bases = [normalizeOllamaApiBase(apiBase)];
|
|
741
|
+
const alternateBase = alternateOllamaApiBase(apiBase);
|
|
742
|
+
if (alternateBase && !bases.includes(alternateBase)) {
|
|
743
|
+
bases.push(alternateBase);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const urls = [];
|
|
747
|
+
for (const base of bases) {
|
|
748
|
+
const openAiUrl = `${base}/models`;
|
|
749
|
+
if (!urls.includes(openAiUrl)) {
|
|
750
|
+
urls.push(openAiUrl);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const legacyBase = base.endsWith('/v1')
|
|
754
|
+
? base.slice(0, -'/v1'.length)
|
|
755
|
+
: base;
|
|
756
|
+
const legacyUrl = `${legacyBase}/api/tags`;
|
|
757
|
+
if (!urls.includes(legacyUrl)) {
|
|
758
|
+
urls.push(legacyUrl);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return urls;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function parseOllamaModels(payload, apiBase = OLLAMA_API_BASE) {
|
|
766
|
+
const normalizedBase = normalizeOllamaApiBase(apiBase);
|
|
767
|
+
const rows = Array.isArray(payload?.models)
|
|
768
|
+
? payload.models
|
|
769
|
+
: Array.isArray(payload?.data)
|
|
770
|
+
? payload.data
|
|
771
|
+
: [];
|
|
772
|
+
const unique = new Map();
|
|
773
|
+
|
|
774
|
+
for (const row of rows) {
|
|
775
|
+
const id = typeof row?.id === 'string'
|
|
776
|
+
? row.id.trim()
|
|
777
|
+
: typeof row?.name === 'string'
|
|
778
|
+
? row.name.trim()
|
|
779
|
+
: '';
|
|
780
|
+
if (!id || unique.has(id)) continue;
|
|
781
|
+
|
|
782
|
+
const displayName = typeof row?.name === 'string' && row.name.trim()
|
|
783
|
+
? row.name.trim()
|
|
784
|
+
: id;
|
|
785
|
+
|
|
786
|
+
unique.set(id, {
|
|
787
|
+
model_id: id,
|
|
788
|
+
display_name: displayName,
|
|
789
|
+
metadata: { api_base: normalizedBase },
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return Array.from(unique.values()).sort((left, right) =>
|
|
794
|
+
left.display_name.toLowerCase().localeCompare(right.display_name.toLowerCase())
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function fetchOllamaModels(apiBase = OLLAMA_API_BASE) {
|
|
799
|
+
for (const url of buildOllamaDiscoveryUrls(apiBase)) {
|
|
800
|
+
try {
|
|
801
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
802
|
+
if (!resp.ok) continue;
|
|
803
|
+
const payload = await resp.json();
|
|
804
|
+
const models = parseOllamaModels(payload, apiBase);
|
|
805
|
+
if (models.length > 0) return models;
|
|
806
|
+
} catch {
|
|
807
|
+
// fall through to the next Ollama discovery endpoint
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return [];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export async function resolveBuiltinProviderModels(builtin) {
|
|
814
|
+
const fallbackModels = builtin.models.map(model => ({
|
|
815
|
+
...model,
|
|
816
|
+
metadata: builtin.api_base ? { api_base: builtin.api_base } : {},
|
|
817
|
+
}));
|
|
818
|
+
|
|
819
|
+
if (builtin.id !== OLLAMA_PROVIDER_ID) {
|
|
820
|
+
return fallbackModels;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const discovered = await fetchOllamaModels(builtin.api_base);
|
|
824
|
+
return discovered.length > 0 ? discovered : fallbackModels;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function upsertProviderSecret(client, keyName, backendKey, value) {
|
|
828
|
+
const existing = await client.listSecrets();
|
|
829
|
+
for (const secret of existing || []) {
|
|
830
|
+
if (secret.key_name === keyName || secret.backend_key === backendKey) {
|
|
831
|
+
await client.deleteSecret(secret.id);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return client.createSecret({
|
|
836
|
+
key_name: keyName,
|
|
837
|
+
backend_key: backendKey,
|
|
838
|
+
policy_label: 'provider-api-key',
|
|
839
|
+
value,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function finalizeOpenAiCodexProviderInstall(client, flags, secretValue, opts = {}) {
|
|
844
|
+
await withSpinner(
|
|
845
|
+
'Storing provider key in vault...',
|
|
846
|
+
() => upsertProviderSecret(client, OPENAI_CODEX_SECRET_KEY_NAME, OPENAI_CODEX_BACKEND_KEY, secretValue),
|
|
847
|
+
'Provider key stored in vault.'
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
const predefinedModels = Array.isArray(opts.models) ? opts.models : null;
|
|
851
|
+
const models = predefinedModels
|
|
852
|
+
? predefinedModels
|
|
853
|
+
: await withSpinner(
|
|
854
|
+
'Syncing models from OpenAI...',
|
|
855
|
+
() => fetchOpenAiModels(secretValue),
|
|
856
|
+
'Model sync completed.'
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
860
|
+
throw new Error('OpenAI model list is empty');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const provider = await withSpinner(
|
|
864
|
+
'Installing OpenAI Codex provider...',
|
|
865
|
+
() => client.installProvider(OPENAI_CODEX_PROVIDER_ID, OPENAI_CODEX_DISPLAY_NAME, models),
|
|
866
|
+
'OpenAI Codex provider installed.'
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
const result = {
|
|
870
|
+
provider_id: OPENAI_CODEX_PROVIDER_ID,
|
|
871
|
+
provider: provider,
|
|
872
|
+
models_count: models.length,
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
if (toBool(flags.json)) {
|
|
876
|
+
console.log(JSON.stringify(result, null, 2));
|
|
877
|
+
} else {
|
|
878
|
+
showResult('Provider Connected', {
|
|
879
|
+
Provider: OPENAI_CODEX_DISPLAY_NAME,
|
|
880
|
+
ID: OPENAI_CODEX_PROVIDER_ID,
|
|
881
|
+
Models: models.length,
|
|
882
|
+
Secret: OPENAI_CODEX_SECRET_KEY_NAME,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return result;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function finalizeOpenAiCodexLogin(client, flags, oauthTokens) {
|
|
890
|
+
const idToken = String(oauthTokens?.id_token || '').trim();
|
|
891
|
+
if (!idToken) {
|
|
892
|
+
throw new Error('OpenAI OAuth code exchange did not return id_token');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const apiKey = await withSpinner(
|
|
896
|
+
'Exchanging OAuth token for API key...',
|
|
897
|
+
() => exchangeCodexApiKey(idToken),
|
|
898
|
+
'Provider key ready.'
|
|
899
|
+
);
|
|
900
|
+
|
|
901
|
+
return finalizeOpenAiCodexProviderInstall(client, flags, apiKey);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async function maybeFinalizeWithCodexSession(client, flags, oauthTokens) {
|
|
905
|
+
const accessToken = String(oauthTokens?.access_token || '').trim();
|
|
906
|
+
const refreshToken = String(oauthTokens?.refresh_token || '').trim();
|
|
907
|
+
if (!accessToken || !refreshToken) {
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const expiresInSec = toPositiveInt(oauthTokens?.expires_in, 3600);
|
|
912
|
+
const expiresAtMs = Date.now() + (Math.max(60, expiresInSec) * 1000);
|
|
913
|
+
const accountId = extractCodexAccountIdFromTokens(oauthTokens);
|
|
914
|
+
const secretValue = buildOpenAiCodexSessionSecret({
|
|
915
|
+
accessToken,
|
|
916
|
+
refreshToken,
|
|
917
|
+
expiresAtMs,
|
|
918
|
+
accountId,
|
|
919
|
+
});
|
|
920
|
+
const models = buildOpenAiCodexSessionModels(accountId);
|
|
921
|
+
|
|
922
|
+
p.log.warn('Falling back to ChatGPT OAuth session mode (OpenCode-compatible).');
|
|
923
|
+
return finalizeOpenAiCodexProviderInstall(client, flags, secretValue, { models });
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function loginOpenAiCodexHeadless(flags) {
|
|
927
|
+
const noBrowser = toBool(flags.no_browser) || toBool(flags.noBrowser);
|
|
928
|
+
const scope = buildOrgScopeFromFlags(flags);
|
|
929
|
+
|
|
930
|
+
const device = await withSpinner(
|
|
931
|
+
'Starting OpenAI OAuth flow...',
|
|
932
|
+
() => requestCodexDeviceCode(scope),
|
|
933
|
+
'OpenAI authorization started.'
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
const verifyUrl = OPENAI_CODEX_DEVICE_VERIFY_URL;
|
|
937
|
+
if (!noBrowser && verifyUrl) {
|
|
938
|
+
tryOpenUrl(verifyUrl);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
p.note(
|
|
942
|
+
[
|
|
943
|
+
'Log in to OpenAI and approve access.',
|
|
944
|
+
verifyUrl ? `Open this URL: ${verifyUrl}` : '',
|
|
945
|
+
device.user_code ? `Verification code: ${device.user_code}` : '',
|
|
946
|
+
].filter(Boolean).join('\n'),
|
|
947
|
+
'OpenAI OAuth'
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const authorization = await withSpinner(
|
|
951
|
+
'Waiting for OpenAI authorization...',
|
|
952
|
+
() => pollCodexAuthorizationCode(
|
|
953
|
+
device.device_auth_id,
|
|
954
|
+
device.user_code,
|
|
955
|
+
toInt(device.interval, 5),
|
|
956
|
+
toInt(device.expires_in, 900)
|
|
957
|
+
),
|
|
958
|
+
'OpenAI authorization completed.'
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
return await withSpinner(
|
|
962
|
+
'Finalizing OpenAI authorization...',
|
|
963
|
+
() => exchangeCodexIdToken({
|
|
964
|
+
authorizationCode: authorization.authorization_code,
|
|
965
|
+
codeVerifier: authorization.code_verifier,
|
|
966
|
+
redirectUri: OPENAI_CODEX_DEVICE_CALLBACK_REDIRECT_URI,
|
|
967
|
+
}),
|
|
968
|
+
'Authorization finalized.'
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function loginOpenAiCodexByMethod(method, flags) {
|
|
973
|
+
return method === 'headless'
|
|
974
|
+
? loginOpenAiCodexHeadless(flags)
|
|
975
|
+
: loginOpenAiCodexBrowser(flags);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function loginOpenAiCodexBrowser(flags) {
|
|
979
|
+
const timeoutMs = Math.max(30_000, toInt(flags.timeout_ms, toInt(flags.timeout_seconds, 180) * 1000));
|
|
980
|
+
const preferredPort = Math.max(1, toInt(flags.port, OPENAI_CODEX_BROWSER_CALLBACK_PORT));
|
|
981
|
+
const noBrowser = toBool(flags.no_browser) || toBool(flags.noBrowser);
|
|
982
|
+
const originator = String(flags.originator || OPENAI_CODEX_ORIGINATOR).trim() || OPENAI_CODEX_ORIGINATOR;
|
|
983
|
+
const allowedWorkspaceId = String(flags.allowed_workspace_id || flags.allowedWorkspaceId || '').trim() || '';
|
|
984
|
+
const organizationId = String(flags.organization_id || flags.organizationId || '').trim() || '';
|
|
985
|
+
const projectId = String(flags.project_id || flags.projectId || '').trim() || '';
|
|
986
|
+
const state = createStateToken();
|
|
987
|
+
const codeVerifier = createCodeVerifier();
|
|
988
|
+
const codeChallenge = buildPkceCodeChallenge(codeVerifier);
|
|
989
|
+
const callbackServer = await withSpinner(
|
|
990
|
+
'Preparing browser OAuth callback...',
|
|
991
|
+
() => startBrowserOAuthCallbackServer({ expectedState: state, timeoutMs, preferredPort }),
|
|
992
|
+
'Browser callback ready.'
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
let authorizationCode;
|
|
996
|
+
try {
|
|
997
|
+
const authorizeUrl = buildCodexBrowserAuthorizeUrl({
|
|
998
|
+
clientId: OPENAI_CODEX_CLIENT_ID,
|
|
999
|
+
redirectUri: callbackServer.redirectUri,
|
|
1000
|
+
codeChallenge,
|
|
1001
|
+
state,
|
|
1002
|
+
originator,
|
|
1003
|
+
allowedWorkspaceId: allowedWorkspaceId || undefined,
|
|
1004
|
+
organizationId: organizationId || undefined,
|
|
1005
|
+
projectId: projectId || undefined,
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
if (!noBrowser) {
|
|
1009
|
+
tryOpenUrl(authorizeUrl);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
p.note(
|
|
1013
|
+
[
|
|
1014
|
+
'Log in to OpenAI and approve access.',
|
|
1015
|
+
`Open this URL: ${authorizeUrl}`,
|
|
1016
|
+
`Callback URL: ${callbackServer.redirectUri}`,
|
|
1017
|
+
].join('\n'),
|
|
1018
|
+
'OpenAI OAuth (Browser)'
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
const result = await withSpinner(
|
|
1022
|
+
'Waiting for browser authorization...',
|
|
1023
|
+
() => callbackServer.waitForAuthorizationCode(),
|
|
1024
|
+
'OpenAI authorization completed.'
|
|
1025
|
+
);
|
|
1026
|
+
authorizationCode = result.authorization_code;
|
|
1027
|
+
} finally {
|
|
1028
|
+
await callbackServer.close();
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return await withSpinner(
|
|
1032
|
+
'Finalizing OpenAI authorization...',
|
|
1033
|
+
() => exchangeCodexIdToken({
|
|
1034
|
+
authorizationCode,
|
|
1035
|
+
codeVerifier,
|
|
1036
|
+
redirectUri: callbackServer.redirectUri,
|
|
1037
|
+
}),
|
|
1038
|
+
'Authorization finalized.'
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function maybeFinalizeWithManualApiKey(client, flags) {
|
|
1043
|
+
const argApiKey = String(flags.api_key || flags['api-key'] || '').trim();
|
|
1044
|
+
if (argApiKey) {
|
|
1045
|
+
p.log.warn('Using manual OpenAI API key from CLI flag fallback.');
|
|
1046
|
+
return await finalizeOpenAiCodexProviderInstall(client, flags, argApiKey);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (!isInteractive()) {
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const useManual = handleCancel(await p.confirm({
|
|
1054
|
+
message: 'OAuth API key issuance failed. Provide OpenAI API key manually to finish provider setup?',
|
|
1055
|
+
initialValue: false,
|
|
1056
|
+
}));
|
|
1057
|
+
if (!useManual) {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const manualApiKey = handleCancel(await p.password({
|
|
1062
|
+
message: 'OpenAI API key',
|
|
1063
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1064
|
+
}));
|
|
1065
|
+
|
|
1066
|
+
p.log.warn('Using manual OpenAI API key fallback.');
|
|
1067
|
+
return await finalizeOpenAiCodexProviderInstall(client, flags, manualApiKey.trim());
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
export async function loginOpenAiCodex(client, flags) {
|
|
1071
|
+
let method = resolveOpenAiAuthMethod(flags);
|
|
1072
|
+
if (!method && isInteractive()) {
|
|
1073
|
+
method = handleCancel(await p.select({
|
|
1074
|
+
message: 'Select OpenAI auth method',
|
|
1075
|
+
options: [
|
|
1076
|
+
{ value: 'browser', label: 'ChatGPT Pro/Plus (browser)', hint: 'recommended: full OAuth link with callback' },
|
|
1077
|
+
{ value: 'headless', label: 'ChatGPT Pro/Plus (headless)', hint: 'device-code flow with verification code' },
|
|
1078
|
+
],
|
|
1079
|
+
}));
|
|
1080
|
+
}
|
|
1081
|
+
if (!method) {
|
|
1082
|
+
method = 'browser';
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const oauthTokens = await loginOpenAiCodexByMethod(method, flags);
|
|
1086
|
+
|
|
1087
|
+
try {
|
|
1088
|
+
return await finalizeOpenAiCodexLogin(client, flags, oauthTokens);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
if (!isMissingOrganizationIdError(err)) {
|
|
1091
|
+
throw err;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
let sessionTokens = oauthTokens;
|
|
1095
|
+
const apiAuthClaims = extractOpenAiAuthClaims(oauthTokens?.id_token);
|
|
1096
|
+
const organizations = listOrganizationCandidates(apiAuthClaims);
|
|
1097
|
+
const alreadyScoped = Boolean(flags.allowed_workspace_id || flags.allowedWorkspaceId || flags.organization_id || flags.organizationId);
|
|
1098
|
+
|
|
1099
|
+
if (!alreadyScoped && organizations.length > 0) {
|
|
1100
|
+
let selectedOrg = organizations.find(o => o.is_default)?.id || organizations[0].id;
|
|
1101
|
+
|
|
1102
|
+
if (isInteractive() && organizations.length > 1) {
|
|
1103
|
+
const chosen = await p.select({
|
|
1104
|
+
message: 'Select OpenAI organization for API key issuance',
|
|
1105
|
+
options: organizations.map(org => ({
|
|
1106
|
+
value: org.id,
|
|
1107
|
+
label: org.title ? `${org.title} (${org.id})` : org.id,
|
|
1108
|
+
hint: org.is_default ? 'default' : undefined,
|
|
1109
|
+
})),
|
|
1110
|
+
});
|
|
1111
|
+
selectedOrg = handleCancel(chosen);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
p.log.warn(`Retrying OAuth with selected organization: ${selectedOrg}`);
|
|
1115
|
+
const retryFlags = buildScopedRetryFlags(flags, selectedOrg, method);
|
|
1116
|
+
const retryOauthTokens = await loginOpenAiCodexByMethod(method, retryFlags);
|
|
1117
|
+
sessionTokens = retryOauthTokens;
|
|
1118
|
+
try {
|
|
1119
|
+
return await finalizeOpenAiCodexLogin(client, flags, retryOauthTokens);
|
|
1120
|
+
} catch (retryErr) {
|
|
1121
|
+
if (!isMissingOrganizationIdError(retryErr)) {
|
|
1122
|
+
throw retryErr;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const sessionFallback = await maybeFinalizeWithCodexSession(client, flags, sessionTokens);
|
|
1128
|
+
if (sessionFallback) {
|
|
1129
|
+
return sessionFallback;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const platformUrl = 'https://platform.openai.com/';
|
|
1133
|
+
p.note(
|
|
1134
|
+
`OpenAI returned an ID token without organization_id. Complete API organization/project setup at ${platformUrl} and retry provider login.`,
|
|
1135
|
+
'OpenAI Setup'
|
|
1136
|
+
);
|
|
1137
|
+
|
|
1138
|
+
const manualFallback = await maybeFinalizeWithManualApiKey(client, flags);
|
|
1139
|
+
if (manualFallback) {
|
|
1140
|
+
return manualFallback;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
throw err;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ── Anthropic Login ───────────────────────────────────────────────────────────
|
|
1148
|
+
|
|
1149
|
+
export function buildAnthropicSessionSecret({ accessToken, refreshToken, expiresAtMs }) {
|
|
1150
|
+
return JSON.stringify({
|
|
1151
|
+
mode: ANTHROPIC_OAUTH_SESSION_MODE,
|
|
1152
|
+
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
|
1153
|
+
access_token: String(accessToken || '').trim(),
|
|
1154
|
+
refresh_token: String(refreshToken || '').trim(),
|
|
1155
|
+
expires_at: toPositiveInt(expiresAtMs, 0),
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function exchangeAnthropicAuthCode({ authorizationCode, codeVerifier, redirectUri }) {
|
|
1160
|
+
// Auth code comes as {code}#{state} - split on #
|
|
1161
|
+
const hashIdx = authorizationCode.indexOf('#');
|
|
1162
|
+
const code = hashIdx >= 0 ? authorizationCode.slice(0, hashIdx) : authorizationCode;
|
|
1163
|
+
const state = hashIdx >= 0 ? authorizationCode.slice(hashIdx + 1) : '';
|
|
1164
|
+
|
|
1165
|
+
const resp = await fetch(ANTHROPIC_OAUTH_TOKEN_ENDPOINT, {
|
|
1166
|
+
method: 'POST',
|
|
1167
|
+
headers: { 'content-type': 'application/json' },
|
|
1168
|
+
body: JSON.stringify({
|
|
1169
|
+
grant_type: 'authorization_code',
|
|
1170
|
+
code,
|
|
1171
|
+
state,
|
|
1172
|
+
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
|
1173
|
+
redirect_uri: redirectUri,
|
|
1174
|
+
code_verifier: codeVerifier,
|
|
1175
|
+
}),
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
const text = await resp.text();
|
|
1179
|
+
const json = parseJsonSafe(text);
|
|
1180
|
+
if (!resp.ok || !json?.access_token) {
|
|
1181
|
+
const msg = json?.error_description || json?.error || text;
|
|
1182
|
+
throw new Error(`Anthropic OAuth code exchange failed (${resp.status}): ${msg}`);
|
|
1183
|
+
}
|
|
1184
|
+
return json;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function extractAnthropicAuthCodeFromInput(rawInput, expectedState) {
|
|
1188
|
+
const input = rawInput.trim();
|
|
1189
|
+
|
|
1190
|
+
// Try parsing as a full callback URL first
|
|
1191
|
+
try {
|
|
1192
|
+
const url = new URL(input);
|
|
1193
|
+
const code = url.searchParams.get('code') || '';
|
|
1194
|
+
const returnedState = url.searchParams.get('state') || '';
|
|
1195
|
+
if (code) {
|
|
1196
|
+
if (expectedState && returnedState && returnedState !== expectedState) {
|
|
1197
|
+
throw new Error('Anthropic OAuth state mismatch - possible CSRF. Please retry.');
|
|
1198
|
+
}
|
|
1199
|
+
return `${code}#${returnedState}`;
|
|
1200
|
+
}
|
|
1201
|
+
} catch {
|
|
1202
|
+
// Not a URL - try as raw code
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Accept raw {code}#{state} or just {code}
|
|
1206
|
+
if (input) return input;
|
|
1207
|
+
throw new Error('Could not extract authorization code from input');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function loginAnthropicOAuth(client, flags) {
|
|
1211
|
+
const noBrowser = toBool(flags.no_browser) || toBool(flags.noBrowser);
|
|
1212
|
+
const state = createStateToken();
|
|
1213
|
+
const codeVerifier = createCodeVerifier();
|
|
1214
|
+
const codeChallenge = buildPkceCodeChallenge(codeVerifier);
|
|
1215
|
+
const redirectUri = ANTHROPIC_OAUTH_REDIRECT_URI;
|
|
1216
|
+
|
|
1217
|
+
const authorizeUrl = new URL(ANTHROPIC_OAUTH_AUTHORIZE_URL);
|
|
1218
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
1219
|
+
authorizeUrl.searchParams.set('client_id', ANTHROPIC_OAUTH_CLIENT_ID);
|
|
1220
|
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
|
|
1221
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
1222
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
1223
|
+
authorizeUrl.searchParams.set('scope', ANTHROPIC_OAUTH_SCOPE);
|
|
1224
|
+
authorizeUrl.searchParams.set('state', state);
|
|
1225
|
+
|
|
1226
|
+
if (!noBrowser) {
|
|
1227
|
+
tryOpenUrl(authorizeUrl.toString());
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
p.note(
|
|
1231
|
+
[
|
|
1232
|
+
'Log in to Anthropic and approve access.',
|
|
1233
|
+
`Open this URL: ${authorizeUrl.toString()}`,
|
|
1234
|
+
'',
|
|
1235
|
+
'After approving, copy the authorization code shown on the page',
|
|
1236
|
+
'(or the full callback URL from your browser address bar) and paste it below.',
|
|
1237
|
+
].join('\n'),
|
|
1238
|
+
'Anthropic OAuth (Claude Plan)'
|
|
1239
|
+
);
|
|
1240
|
+
|
|
1241
|
+
if (!isInteractive()) {
|
|
1242
|
+
throw new Error('Anthropic OAuth login requires interactive mode. Use --method api-key for non-interactive.');
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const callbackInput = handleCancel(await p.text({
|
|
1246
|
+
message: 'Paste authorization code or callback URL',
|
|
1247
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1248
|
+
}));
|
|
1249
|
+
|
|
1250
|
+
const authorizationCode = extractAnthropicAuthCodeFromInput(callbackInput, state);
|
|
1251
|
+
|
|
1252
|
+
const tokens = await withSpinner(
|
|
1253
|
+
'Exchanging authorization code...',
|
|
1254
|
+
() => exchangeAnthropicAuthCode({
|
|
1255
|
+
authorizationCode,
|
|
1256
|
+
codeVerifier,
|
|
1257
|
+
redirectUri,
|
|
1258
|
+
}),
|
|
1259
|
+
'Token exchange completed.'
|
|
1260
|
+
);
|
|
1261
|
+
|
|
1262
|
+
const expiresInSec = toPositiveInt(tokens.expires_in, 28800);
|
|
1263
|
+
const expiresAtMs = Date.now() + (Math.max(60, expiresInSec) * 1000);
|
|
1264
|
+
const secretValue = buildAnthropicSessionSecret({
|
|
1265
|
+
accessToken: tokens.access_token,
|
|
1266
|
+
refreshToken: tokens.refresh_token,
|
|
1267
|
+
expiresAtMs,
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
await withSpinner(
|
|
1271
|
+
'Storing provider key in vault...',
|
|
1272
|
+
() => upsertProviderSecret(client, ANTHROPIC_SECRET_KEY_NAME, ANTHROPIC_BACKEND_KEY, secretValue),
|
|
1273
|
+
'Provider key stored in vault.'
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
const builtin = BUILTIN_PROVIDERS.find(bp => bp.id === ANTHROPIC_PROVIDER_ID);
|
|
1277
|
+
const models = builtin.models.map(m => ({
|
|
1278
|
+
...m,
|
|
1279
|
+
metadata: { api_base: ANTHROPIC_API_BASE },
|
|
1280
|
+
}));
|
|
1281
|
+
|
|
1282
|
+
const provider = await withSpinner(
|
|
1283
|
+
'Installing Anthropic provider...',
|
|
1284
|
+
() => client.installProvider(ANTHROPIC_PROVIDER_ID, builtin.display_name, models),
|
|
1285
|
+
'Anthropic provider installed.'
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
const result = {
|
|
1289
|
+
provider_id: ANTHROPIC_PROVIDER_ID,
|
|
1290
|
+
provider,
|
|
1291
|
+
models_count: models.length,
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
if (toBool(flags.json)) {
|
|
1295
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1296
|
+
} else {
|
|
1297
|
+
showResult('Provider Connected', {
|
|
1298
|
+
Provider: builtin.display_name,
|
|
1299
|
+
ID: ANTHROPIC_PROVIDER_ID,
|
|
1300
|
+
Models: models.length,
|
|
1301
|
+
Secret: ANTHROPIC_SECRET_KEY_NAME,
|
|
1302
|
+
Auth: 'Claude Plan (OAuth)',
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
return result;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
async function loginAnthropicApiKey(client, flags) {
|
|
1310
|
+
const argApiKey = String(flags.api_key || flags['api-key'] || '').trim();
|
|
1311
|
+
let apiKey = argApiKey;
|
|
1312
|
+
|
|
1313
|
+
if (!apiKey) {
|
|
1314
|
+
if (!isInteractive()) {
|
|
1315
|
+
throw new Error('Anthropic login requires --api-key in non-interactive mode');
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
p.note(
|
|
1319
|
+
'Get your API key at: https://console.anthropic.com/settings/keys',
|
|
1320
|
+
'Anthropic API Key'
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
apiKey = handleCancel(await p.password({
|
|
1324
|
+
message: 'Anthropic API key',
|
|
1325
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1326
|
+
}));
|
|
1327
|
+
apiKey = apiKey.trim();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Validate by calling the models endpoint
|
|
1331
|
+
await withSpinner('Validating API key...', async () => {
|
|
1332
|
+
const resp = await fetch(`${ANTHROPIC_API_BASE}/v1/models`, {
|
|
1333
|
+
headers: {
|
|
1334
|
+
'x-api-key': apiKey,
|
|
1335
|
+
'anthropic-version': '2023-06-01',
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
if (!resp.ok) {
|
|
1339
|
+
const text = await resp.text();
|
|
1340
|
+
const json = parseJsonSafe(text);
|
|
1341
|
+
const msg = json?.error?.message || text;
|
|
1342
|
+
throw new Error(`Anthropic API key validation failed (${resp.status}): ${msg}`);
|
|
1343
|
+
}
|
|
1344
|
+
}, 'API key is valid.');
|
|
1345
|
+
|
|
1346
|
+
await withSpinner(
|
|
1347
|
+
'Storing provider key in vault...',
|
|
1348
|
+
() => upsertProviderSecret(client, ANTHROPIC_SECRET_KEY_NAME, ANTHROPIC_BACKEND_KEY, apiKey),
|
|
1349
|
+
'Provider key stored in vault.'
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1352
|
+
const builtin = BUILTIN_PROVIDERS.find(bp => bp.id === ANTHROPIC_PROVIDER_ID);
|
|
1353
|
+
const models = builtin.models.map(m => ({
|
|
1354
|
+
...m,
|
|
1355
|
+
metadata: { api_base: ANTHROPIC_API_BASE },
|
|
1356
|
+
}));
|
|
1357
|
+
|
|
1358
|
+
const provider = await withSpinner(
|
|
1359
|
+
'Installing Anthropic provider...',
|
|
1360
|
+
() => client.installProvider(ANTHROPIC_PROVIDER_ID, builtin.display_name, models),
|
|
1361
|
+
'Anthropic provider installed.'
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
const result = {
|
|
1365
|
+
provider_id: ANTHROPIC_PROVIDER_ID,
|
|
1366
|
+
provider,
|
|
1367
|
+
models_count: models.length,
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
if (toBool(flags.json)) {
|
|
1371
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1372
|
+
} else {
|
|
1373
|
+
showResult('Provider Connected', {
|
|
1374
|
+
Provider: builtin.display_name,
|
|
1375
|
+
ID: ANTHROPIC_PROVIDER_ID,
|
|
1376
|
+
Models: models.length,
|
|
1377
|
+
Secret: ANTHROPIC_SECRET_KEY_NAME,
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
return result;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
export async function loginAnthropic(client, flags) {
|
|
1385
|
+
let method = String(flags.method || '').trim().toLowerCase();
|
|
1386
|
+
|
|
1387
|
+
// --api-key flag → direct API key flow
|
|
1388
|
+
if (flags.api_key || flags['api-key']) method = 'api-key';
|
|
1389
|
+
|
|
1390
|
+
if (!method && isInteractive()) {
|
|
1391
|
+
method = handleCancel(await p.select({
|
|
1392
|
+
message: 'Select Anthropic auth method',
|
|
1393
|
+
options: [
|
|
1394
|
+
{ value: 'oauth', label: 'Claude Plan (OAuth)', hint: 'recommended: Pro/Max subscription' },
|
|
1395
|
+
{ value: 'api-key', label: 'API Key', hint: 'from console.anthropic.com' },
|
|
1396
|
+
],
|
|
1397
|
+
}));
|
|
1398
|
+
}
|
|
1399
|
+
if (!method) method = 'oauth';
|
|
1400
|
+
|
|
1401
|
+
if (method === 'api-key') return loginAnthropicApiKey(client, flags);
|
|
1402
|
+
return loginAnthropicOAuth(client, flags);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// ── Provider Credential Check ──────────────────────────────────────────────
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Verify credentials for a provider. For CLI-based providers this checks the
|
|
1409
|
+
* binary + auth status; for API providers it checks the env-var key and
|
|
1410
|
+
* optionally stores it in the vault.
|
|
1411
|
+
*
|
|
1412
|
+
* Returns `false` if the provider cannot be used (missing binary), `true`
|
|
1413
|
+
* otherwise (warnings are logged but do not block installation).
|
|
1414
|
+
*/
|
|
1415
|
+
export async function checkProviderCredentials(builtin, client) {
|
|
1416
|
+
if (builtin.cli_binary) {
|
|
1417
|
+
const { execFileSync } = await import('node:child_process');
|
|
1418
|
+
try {
|
|
1419
|
+
const binPath = execFileSync('which', [builtin.cli_binary], { encoding: 'utf8' }).trim();
|
|
1420
|
+
p.log.success(`${builtin.cli_binary} found at: ${binPath}`);
|
|
1421
|
+
|
|
1422
|
+
try {
|
|
1423
|
+
const authOut = execFileSync(binPath, ['auth', 'status', '--json'], { encoding: 'utf8', timeout: 10_000 });
|
|
1424
|
+
const auth = JSON.parse(authOut);
|
|
1425
|
+
if (auth.authenticated || auth.loggedIn) {
|
|
1426
|
+
p.log.success(`Authenticated as: ${auth.email || auth.account || 'unknown'}`);
|
|
1427
|
+
} else {
|
|
1428
|
+
p.log.warn(`${builtin.cli_binary} is not authenticated. Run: ${builtin.cli_binary} auth login`);
|
|
1429
|
+
}
|
|
1430
|
+
} catch {
|
|
1431
|
+
p.log.warn(`Could not check auth status. Make sure you are logged in: ${builtin.cli_binary} auth login`);
|
|
1432
|
+
}
|
|
1433
|
+
} catch {
|
|
1434
|
+
p.log.error(`${builtin.cli_binary} binary not found. Install it first: https://docs.anthropic.com/en/docs/claude-code`);
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1437
|
+
} else if (builtin.id === OLLAMA_PROVIDER_ID) {
|
|
1438
|
+
try {
|
|
1439
|
+
const discovered = await fetchOllamaModels(builtin.api_base);
|
|
1440
|
+
if (discovered.length > 0) {
|
|
1441
|
+
p.log.success(`Local Ollama detected (${discovered.length} models discovered).`);
|
|
1442
|
+
} else {
|
|
1443
|
+
p.log.warn('Could not discover local Ollama models. Static catalog will be used as fallback.');
|
|
1444
|
+
}
|
|
1445
|
+
} catch {
|
|
1446
|
+
p.log.warn('Could not reach local Ollama. Static catalog will be used as fallback.');
|
|
1447
|
+
}
|
|
1448
|
+
} else if (builtin.api_key_env) {
|
|
1449
|
+
const currentKey = process.env[builtin.api_key_env];
|
|
1450
|
+
if (currentKey) {
|
|
1451
|
+
const masked = currentKey.slice(0, 8) + '...' + currentKey.slice(-4);
|
|
1452
|
+
p.log.success(`API key found: ${builtin.api_key_env} = ${masked}`);
|
|
1453
|
+
} else {
|
|
1454
|
+
p.log.warn(`API key not set: ${builtin.api_key_env}`);
|
|
1455
|
+
|
|
1456
|
+
const setKey = handleCancel(await p.confirm({
|
|
1457
|
+
message: `Store API key via vault? (You can also set ${builtin.api_key_env} in your shell)`,
|
|
1458
|
+
initialValue: false,
|
|
1459
|
+
}));
|
|
1460
|
+
|
|
1461
|
+
if (setKey) {
|
|
1462
|
+
const apiKey = handleCancel(await p.password({
|
|
1463
|
+
message: `Enter your ${builtin.display_name} API key`,
|
|
1464
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1465
|
+
}));
|
|
1466
|
+
|
|
1467
|
+
try {
|
|
1468
|
+
await withSpinner('Storing API key in vault...', async () => {
|
|
1469
|
+
await client.request('/v1/vault/secrets', 'POST', {
|
|
1470
|
+
key_name: builtin.api_key_env,
|
|
1471
|
+
backend_key: `moxxy_provider_${builtin.id}`,
|
|
1472
|
+
policy_label: 'provider-api-key',
|
|
1473
|
+
value: apiKey,
|
|
1474
|
+
});
|
|
1475
|
+
}, 'API key reference stored.');
|
|
1476
|
+
|
|
1477
|
+
p.note(
|
|
1478
|
+
`export ${builtin.api_key_env}="${apiKey}"`,
|
|
1479
|
+
'Also add to your shell profile for direct access'
|
|
1480
|
+
);
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
p.log.warn(`Could not store in vault: ${err.message}`);
|
|
1483
|
+
p.note(
|
|
1484
|
+
`export ${builtin.api_key_env}="<your-key>"`,
|
|
1485
|
+
'Set this in your shell profile'
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
} else {
|
|
1489
|
+
p.note(
|
|
1490
|
+
`export ${builtin.api_key_env}="<your-key>"`,
|
|
1491
|
+
'Set this in your shell profile'
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return true;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// ── Built-in Provider Catalog ────────────────────────────────────────────────
|
|
1500
|
+
|
|
1501
|
+
export const BUILTIN_PROVIDERS = [
|
|
1502
|
+
{
|
|
1503
|
+
id: 'anthropic',
|
|
1504
|
+
display_name: 'Anthropic',
|
|
1505
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
1506
|
+
api_base: 'https://api.anthropic.com',
|
|
1507
|
+
api_key_login: true,
|
|
1508
|
+
oauth_login: true,
|
|
1509
|
+
models: [
|
|
1510
|
+
{ model_id: 'claude-sonnet-5-20260203', display_name: 'Claude Sonnet 5 "Fennec"' },
|
|
1511
|
+
{ model_id: 'claude-opus-4-20250514', display_name: 'Claude Opus 4' },
|
|
1512
|
+
{ model_id: 'claude-sonnet-4-20250514', display_name: 'Claude Sonnet 4' },
|
|
1513
|
+
{ model_id: 'claude-haiku-4-20250506', display_name: 'Claude Haiku 4' },
|
|
1514
|
+
{ model_id: 'claude-3-5-sonnet-20241022', display_name: 'Claude 3.5 Sonnet' },
|
|
1515
|
+
{ model_id: 'claude-3-5-haiku-20241022', display_name: 'Claude 3.5 Haiku' },
|
|
1516
|
+
],
|
|
1517
|
+
},
|
|
1518
|
+
{
|
|
1519
|
+
id: 'openai',
|
|
1520
|
+
display_name: 'OpenAI',
|
|
1521
|
+
api_key_env: 'OPENAI_API_KEY',
|
|
1522
|
+
api_base: OPENAI_API_BASE,
|
|
1523
|
+
models: [
|
|
1524
|
+
{ model_id: 'gpt-5.2', display_name: 'GPT-5.2' },
|
|
1525
|
+
{ model_id: 'gpt-4.1', display_name: 'GPT-4.1' },
|
|
1526
|
+
{ model_id: 'gpt-4.1-mini', display_name: 'GPT-4.1 Mini' },
|
|
1527
|
+
{ model_id: 'gpt-4.1-nano', display_name: 'GPT-4.1 Nano' },
|
|
1528
|
+
{ model_id: 'o3', display_name: 'o3' },
|
|
1529
|
+
{ model_id: 'o4-mini', display_name: 'o4-mini' },
|
|
1530
|
+
{ model_id: 'gpt-4o', display_name: 'GPT-4o' },
|
|
1531
|
+
{ model_id: 'gpt-4o-mini', display_name: 'GPT-4o Mini' },
|
|
1532
|
+
],
|
|
1533
|
+
},
|
|
1534
|
+
{
|
|
1535
|
+
id: OPENAI_CODEX_PROVIDER_ID,
|
|
1536
|
+
display_name: OPENAI_CODEX_DISPLAY_NAME,
|
|
1537
|
+
api_key_env: OPENAI_CODEX_SECRET_KEY_NAME,
|
|
1538
|
+
api_base: OPENAI_API_BASE,
|
|
1539
|
+
oauth_login: true,
|
|
1540
|
+
models: [
|
|
1541
|
+
{ model_id: 'gpt-5.2', display_name: 'GPT-5.2' },
|
|
1542
|
+
{ model_id: 'gpt-4.1', display_name: 'GPT-4.1' },
|
|
1543
|
+
{ model_id: 'gpt-4.1-mini', display_name: 'GPT-4.1 Mini' },
|
|
1544
|
+
{ model_id: 'gpt-4.1-nano', display_name: 'GPT-4.1 Nano' },
|
|
1545
|
+
{ model_id: 'o3', display_name: 'o3' },
|
|
1546
|
+
{ model_id: 'o4-mini', display_name: 'o4-mini' },
|
|
1547
|
+
{ model_id: 'gpt-4o', display_name: 'GPT-4o' },
|
|
1548
|
+
{ model_id: 'gpt-4o-mini', display_name: 'GPT-4o Mini' },
|
|
1549
|
+
],
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
id: OLLAMA_PROVIDER_ID,
|
|
1553
|
+
display_name: 'Ollama',
|
|
1554
|
+
api_base: OLLAMA_API_BASE,
|
|
1555
|
+
models: [
|
|
1556
|
+
{ model_id: 'qwen3:8b', display_name: 'Qwen 3 8B' },
|
|
1557
|
+
{ model_id: 'gemma3', display_name: 'Gemma 3' },
|
|
1558
|
+
{ model_id: 'gpt-oss:20b', display_name: 'GPT OSS 20B' },
|
|
1559
|
+
],
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
id: 'xai',
|
|
1563
|
+
display_name: 'xAI',
|
|
1564
|
+
api_key_env: 'XAI_API_KEY',
|
|
1565
|
+
api_base: 'https://api.x.ai/v1',
|
|
1566
|
+
models: [
|
|
1567
|
+
{ model_id: 'grok-4', display_name: 'Grok 4' },
|
|
1568
|
+
{ model_id: 'grok-3', display_name: 'Grok 3' },
|
|
1569
|
+
{ model_id: 'grok-3-mini', display_name: 'Grok 3 Mini' },
|
|
1570
|
+
{ model_id: 'grok-3-fast', display_name: 'Grok 3 Fast' },
|
|
1571
|
+
{ model_id: 'grok-2', display_name: 'Grok 2' },
|
|
1572
|
+
],
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
1575
|
+
id: 'google',
|
|
1576
|
+
display_name: 'Google Gemini',
|
|
1577
|
+
api_key_env: 'GOOGLE_API_KEY',
|
|
1578
|
+
api_base: 'https://generativelanguage.googleapis.com/v1beta',
|
|
1579
|
+
models: [
|
|
1580
|
+
{ model_id: 'gemini-3.1-pro', display_name: 'Gemini 3.1 Pro' },
|
|
1581
|
+
{ model_id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' },
|
|
1582
|
+
{ model_id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' },
|
|
1583
|
+
{ model_id: 'gemini-2.0-flash', display_name: 'Gemini 2.0 Flash' },
|
|
1584
|
+
],
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
id: 'deepseek',
|
|
1588
|
+
display_name: 'DeepSeek',
|
|
1589
|
+
api_key_env: 'DEEPSEEK_API_KEY',
|
|
1590
|
+
api_base: 'https://api.deepseek.com',
|
|
1591
|
+
models: [
|
|
1592
|
+
{ model_id: 'deepseek-v4', display_name: 'DeepSeek V4' },
|
|
1593
|
+
{ model_id: 'deepseek-r1', display_name: 'DeepSeek R1' },
|
|
1594
|
+
{ model_id: 'deepseek-v3', display_name: 'DeepSeek V3' },
|
|
1595
|
+
],
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
id: 'zai',
|
|
1599
|
+
display_name: 'ZAI',
|
|
1600
|
+
api_key_env: 'ZAI_API_KEY',
|
|
1601
|
+
api_base: 'https://api.zai.com/v1',
|
|
1602
|
+
models: [
|
|
1603
|
+
{ model_id: 'zai-pro', display_name: 'ZAI Pro' },
|
|
1604
|
+
{ model_id: 'zai-standard', display_name: 'ZAI Standard' },
|
|
1605
|
+
{ model_id: 'zai-fast', display_name: 'ZAI Fast' },
|
|
1606
|
+
],
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
id: 'zai-plan',
|
|
1610
|
+
display_name: 'ZAI Plan',
|
|
1611
|
+
api_key_env: 'ZAI_API_KEY',
|
|
1612
|
+
api_base: 'https://api.zai.com/v1',
|
|
1613
|
+
models: [
|
|
1614
|
+
{ model_id: 'zai-plan-pro', display_name: 'ZAI Plan Pro' },
|
|
1615
|
+
{ model_id: 'zai-plan-standard', display_name: 'ZAI Plan Standard' },
|
|
1616
|
+
],
|
|
1617
|
+
},
|
|
1618
|
+
{
|
|
1619
|
+
id: 'claude-cli',
|
|
1620
|
+
display_name: 'Claude Code CLI',
|
|
1621
|
+
cli_binary: 'claude',
|
|
1622
|
+
models: [
|
|
1623
|
+
{ model_id: 'opus', display_name: 'Claude Opus' },
|
|
1624
|
+
{ model_id: 'sonnet', display_name: 'Claude Sonnet' },
|
|
1625
|
+
{ model_id: 'haiku', display_name: 'Claude Haiku' },
|
|
1626
|
+
],
|
|
1627
|
+
},
|
|
1628
|
+
];
|
|
1629
|
+
|
|
1630
|
+
// ── CLI Command ──────────────────────────────────────────────────────────────
|
|
1631
|
+
|
|
1632
|
+
export async function runProvider(client, args) {
|
|
1633
|
+
let [action, ...rest] = args;
|
|
1634
|
+
const flags = parseFlags(rest);
|
|
1635
|
+
|
|
1636
|
+
// Interactive sub-menu when no action
|
|
1637
|
+
if (!action && isInteractive()) {
|
|
1638
|
+
action = await p.select({
|
|
1639
|
+
message: 'Provider action',
|
|
1640
|
+
options: [
|
|
1641
|
+
{ value: 'install', label: 'Install provider', hint: 'add a built-in or custom provider' },
|
|
1642
|
+
{ value: 'login', label: 'Login provider', hint: 'OAuth/subscription login for supported providers' },
|
|
1643
|
+
{ value: 'list', label: 'List providers', hint: 'show installed providers' },
|
|
1644
|
+
],
|
|
1645
|
+
});
|
|
1646
|
+
handleCancel(action);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
switch (action) {
|
|
1650
|
+
case 'list': {
|
|
1651
|
+
let result;
|
|
1652
|
+
if (isInteractive()) {
|
|
1653
|
+
result = await withSpinner('Fetching providers...', () =>
|
|
1654
|
+
client.listProviders(), 'Providers loaded.');
|
|
1655
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
1656
|
+
for (const pr of result) {
|
|
1657
|
+
const status = pr.enabled ? 'enabled' : 'disabled';
|
|
1658
|
+
p.log.info(`${pr.display_name || pr.id} (${pr.id}) [${status}]`);
|
|
1659
|
+
}
|
|
1660
|
+
} else {
|
|
1661
|
+
p.log.warn('No providers installed. Run: moxxy provider install');
|
|
1662
|
+
}
|
|
1663
|
+
} else {
|
|
1664
|
+
result = await client.listProviders();
|
|
1665
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1666
|
+
}
|
|
1667
|
+
return result;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
case 'install': {
|
|
1671
|
+
if (isInteractive()) {
|
|
1672
|
+
return await installInteractive(client);
|
|
1673
|
+
}
|
|
1674
|
+
return await installNonInteractive(client, flags);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
case 'login': {
|
|
1678
|
+
return await loginProvider(client, flags);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
default:
|
|
1682
|
+
if (!action) {
|
|
1683
|
+
console.error('Usage: moxxy provider <install|login|list>');
|
|
1684
|
+
} else {
|
|
1685
|
+
console.error(`Unknown provider action: ${action}`);
|
|
1686
|
+
}
|
|
1687
|
+
process.exitCode = 1;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
async function loginProvider(client, flags) {
|
|
1692
|
+
let providerId = flags.id || flags.provider;
|
|
1693
|
+
|
|
1694
|
+
if (!providerId && isInteractive()) {
|
|
1695
|
+
providerId = handleCancel(await p.select({
|
|
1696
|
+
message: 'Select provider to log in',
|
|
1697
|
+
options: [
|
|
1698
|
+
{ value: OPENAI_CODEX_PROVIDER_ID, label: 'OpenAI (Codex OAuth)', hint: 'ChatGPT Pro/Plus OAuth' },
|
|
1699
|
+
{ value: ANTHROPIC_PROVIDER_ID, label: 'Anthropic', hint: 'Claude plan (OAuth) or API key' },
|
|
1700
|
+
],
|
|
1701
|
+
}));
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
if (!providerId) {
|
|
1705
|
+
providerId = OPENAI_CODEX_PROVIDER_ID;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (providerId === ANTHROPIC_PROVIDER_ID) return loginAnthropic(client, flags);
|
|
1709
|
+
if (providerId === OPENAI_CODEX_PROVIDER_ID) return loginOpenAiCodex(client, flags);
|
|
1710
|
+
|
|
1711
|
+
throw new Error(`Provider login supported for: ${ANTHROPIC_PROVIDER_ID}, ${OPENAI_CODEX_PROVIDER_ID}`);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ── Interactive Install Wizard ───────────────────────────────────────────────
|
|
1715
|
+
|
|
1716
|
+
async function installInteractive(client) {
|
|
1717
|
+
p.intro('Install Provider');
|
|
1718
|
+
|
|
1719
|
+
// Step 1: Choose built-in or custom
|
|
1720
|
+
const providerChoice = await p.select({
|
|
1721
|
+
message: 'Select a provider to install',
|
|
1722
|
+
options: [
|
|
1723
|
+
...BUILTIN_PROVIDERS.map(bp => ({
|
|
1724
|
+
value: bp.id,
|
|
1725
|
+
label: bp.display_name,
|
|
1726
|
+
hint: `${bp.models.length} models`,
|
|
1727
|
+
})),
|
|
1728
|
+
{ value: '__custom__', label: 'Custom provider', hint: 'OpenAI-compatible endpoint' },
|
|
1729
|
+
],
|
|
1730
|
+
});
|
|
1731
|
+
handleCancel(providerChoice);
|
|
1732
|
+
|
|
1733
|
+
let providerId, displayName, models, apiKeyEnv, apiBase;
|
|
1734
|
+
|
|
1735
|
+
if (providerChoice === '__custom__') {
|
|
1736
|
+
// Custom provider flow
|
|
1737
|
+
providerId = handleCancel(await p.text({
|
|
1738
|
+
message: 'Provider ID',
|
|
1739
|
+
placeholder: 'my-provider',
|
|
1740
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1741
|
+
}));
|
|
1742
|
+
|
|
1743
|
+
displayName = handleCancel(await p.text({
|
|
1744
|
+
message: 'Display name',
|
|
1745
|
+
placeholder: 'My Provider',
|
|
1746
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1747
|
+
}));
|
|
1748
|
+
|
|
1749
|
+
apiBase = handleCancel(await p.text({
|
|
1750
|
+
message: 'API base URL',
|
|
1751
|
+
placeholder: 'https://api.example.com/v1',
|
|
1752
|
+
validate: (v) => {
|
|
1753
|
+
try { new URL(v); } catch { return 'Must be a valid URL'; }
|
|
1754
|
+
},
|
|
1755
|
+
}));
|
|
1756
|
+
|
|
1757
|
+
apiKeyEnv = handleCancel(await p.text({
|
|
1758
|
+
message: 'API key environment variable name',
|
|
1759
|
+
placeholder: 'MY_PROVIDER_API_KEY',
|
|
1760
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1761
|
+
}));
|
|
1762
|
+
|
|
1763
|
+
// Custom models
|
|
1764
|
+
models = [];
|
|
1765
|
+
let addMore = true;
|
|
1766
|
+
while (addMore) {
|
|
1767
|
+
const modelId = handleCancel(await p.text({
|
|
1768
|
+
message: 'Model ID',
|
|
1769
|
+
placeholder: 'model-name',
|
|
1770
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1771
|
+
}));
|
|
1772
|
+
|
|
1773
|
+
const modelName = handleCancel(await p.text({
|
|
1774
|
+
message: 'Model display name',
|
|
1775
|
+
initialValue: modelId,
|
|
1776
|
+
}));
|
|
1777
|
+
|
|
1778
|
+
models.push({
|
|
1779
|
+
model_id: modelId,
|
|
1780
|
+
display_name: modelName || modelId,
|
|
1781
|
+
metadata: { api_base: apiBase },
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
addMore = handleCancel(await p.confirm({
|
|
1785
|
+
message: 'Add another model?',
|
|
1786
|
+
initialValue: false,
|
|
1787
|
+
}));
|
|
1788
|
+
}
|
|
1789
|
+
} else {
|
|
1790
|
+
// Built-in provider
|
|
1791
|
+
const builtin = BUILTIN_PROVIDERS.find(bp => bp.id === providerChoice);
|
|
1792
|
+
providerId = builtin.id;
|
|
1793
|
+
displayName = builtin.display_name;
|
|
1794
|
+
apiKeyEnv = builtin.api_key_env;
|
|
1795
|
+
apiBase = builtin.api_base;
|
|
1796
|
+
const availableModels = await resolveBuiltinProviderModels(builtin);
|
|
1797
|
+
|
|
1798
|
+
// Step 2: Select which models to install
|
|
1799
|
+
const CUSTOM_MODEL_VALUE = '__custom_model__';
|
|
1800
|
+
|
|
1801
|
+
const selectedModels = handleCancel(await p.multiselect({
|
|
1802
|
+
message: 'Select models to install',
|
|
1803
|
+
options: [
|
|
1804
|
+
...availableModels.map(m => ({
|
|
1805
|
+
value: m.model_id,
|
|
1806
|
+
label: m.display_name,
|
|
1807
|
+
hint: m.model_id,
|
|
1808
|
+
})),
|
|
1809
|
+
{ value: CUSTOM_MODEL_VALUE, label: 'Custom model ID', hint: 'enter a model ID manually' },
|
|
1810
|
+
],
|
|
1811
|
+
required: true,
|
|
1812
|
+
}));
|
|
1813
|
+
|
|
1814
|
+
models = availableModels
|
|
1815
|
+
.filter(m => selectedModels.includes(m.model_id))
|
|
1816
|
+
.map(m => ({
|
|
1817
|
+
...m,
|
|
1818
|
+
metadata: m.metadata || (apiBase ? { api_base: apiBase } : {}),
|
|
1819
|
+
}));
|
|
1820
|
+
|
|
1821
|
+
// Prompt for custom model details if selected
|
|
1822
|
+
if (selectedModels.includes(CUSTOM_MODEL_VALUE)) {
|
|
1823
|
+
const customModelId = handleCancel(await p.text({
|
|
1824
|
+
message: 'Custom model ID',
|
|
1825
|
+
placeholder: 'e.g. ft:gpt-4o:my-org:custom-suffix',
|
|
1826
|
+
validate: (v) => { if (!v.trim()) return 'Required'; },
|
|
1827
|
+
}));
|
|
1828
|
+
|
|
1829
|
+
const customModelName = handleCancel(await p.text({
|
|
1830
|
+
message: 'Display name for this model',
|
|
1831
|
+
initialValue: customModelId,
|
|
1832
|
+
}));
|
|
1833
|
+
|
|
1834
|
+
models.push({
|
|
1835
|
+
model_id: customModelId,
|
|
1836
|
+
display_name: customModelName || customModelId,
|
|
1837
|
+
metadata: apiBase ? { api_base: apiBase, custom: true } : { custom: true },
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
const builtin = BUILTIN_PROVIDERS.find(bp => bp.id === providerId);
|
|
1843
|
+
|
|
1844
|
+
if (builtin?.oauth_login || builtin?.api_key_login) {
|
|
1845
|
+
// Providers with dedicated login: delegate to the login flow which handles
|
|
1846
|
+
// OAuth/API-key auth, vault storage, and provider installation in one step.
|
|
1847
|
+
const flags = {};
|
|
1848
|
+
if (providerId === ANTHROPIC_PROVIDER_ID) return loginAnthropic(client, flags);
|
|
1849
|
+
if (providerId === OPENAI_CODEX_PROVIDER_ID) return loginOpenAiCodex(client, flags);
|
|
1850
|
+
|
|
1851
|
+
// Fallback for future login-enabled providers
|
|
1852
|
+
p.log.info(`Run: moxxy provider login --id ${providerId}`);
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// Verify credentials (binary check for CLI providers, API key for others)
|
|
1857
|
+
const credOk = await checkProviderCredentials(
|
|
1858
|
+
builtin || { id: providerId, display_name: displayName, api_key_env: apiKeyEnv },
|
|
1859
|
+
client,
|
|
1860
|
+
);
|
|
1861
|
+
if (!credOk) return;
|
|
1862
|
+
|
|
1863
|
+
// Step 3: Install provider via API
|
|
1864
|
+
const result = await withSpinner(`Installing ${displayName}...`, () =>
|
|
1865
|
+
client.installProvider(providerId, displayName, models),
|
|
1866
|
+
`${displayName} installed.`
|
|
1867
|
+
);
|
|
1868
|
+
|
|
1869
|
+
const resultInfo = {
|
|
1870
|
+
ID: providerId,
|
|
1871
|
+
Name: displayName,
|
|
1872
|
+
Models: models.map(m => m.model_id).join(', '),
|
|
1873
|
+
};
|
|
1874
|
+
if (apiKeyEnv) resultInfo['API Key Env'] = apiKeyEnv;
|
|
1875
|
+
if (builtin?.cli_binary) resultInfo['CLI Binary'] = builtin.cli_binary;
|
|
1876
|
+
showResult('Provider Installed', resultInfo);
|
|
1877
|
+
|
|
1878
|
+
p.outro('Provider ready. Create an agent with: moxxy agent create');
|
|
1879
|
+
return result;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// ── Non-Interactive Install ──────────────────────────────────────────────────
|
|
1883
|
+
|
|
1884
|
+
async function installNonInteractive(client, flags) {
|
|
1885
|
+
const providerId = flags.id || flags.provider;
|
|
1886
|
+
|
|
1887
|
+
// Check if it's a built-in provider
|
|
1888
|
+
const builtin = BUILTIN_PROVIDERS.find(bp => bp.id === providerId);
|
|
1889
|
+
|
|
1890
|
+
// Normalize --model to an array (parseFlags collects repeated --model flags)
|
|
1891
|
+
const extraModels = Array.isArray(flags.model) ? flags.model : (flags.model ? [flags.model] : []);
|
|
1892
|
+
|
|
1893
|
+
if (builtin) {
|
|
1894
|
+
const builtinIds = new Set(builtin.models.map(m => m.model_id));
|
|
1895
|
+
const models = await resolveBuiltinProviderModels(builtin);
|
|
1896
|
+
const knownModelIds = new Set(models.map(m => m.model_id));
|
|
1897
|
+
|
|
1898
|
+
// Add custom models that aren't already in the builtin catalog
|
|
1899
|
+
for (const modelId of extraModels) {
|
|
1900
|
+
if (!builtinIds.has(modelId) && !knownModelIds.has(modelId)) {
|
|
1901
|
+
models.push({
|
|
1902
|
+
model_id: modelId,
|
|
1903
|
+
display_name: modelId,
|
|
1904
|
+
metadata: builtin.api_base ? { api_base: builtin.api_base, custom: true } : { custom: true },
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const result = await client.installProvider(builtin.id, builtin.display_name, models);
|
|
1910
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1911
|
+
return result;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Custom provider
|
|
1915
|
+
if (!providerId) {
|
|
1916
|
+
throw new Error('Required: --id (provider id). Built-in: openai, openai-codex, anthropic, ollama, xai, zai, zai-plan, claude-cli');
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const displayName = flags.name || flags.display_name || providerId;
|
|
1920
|
+
const apiBase = flags.api_base || flags.url;
|
|
1921
|
+
const models = [];
|
|
1922
|
+
|
|
1923
|
+
for (const modelId of extraModels) {
|
|
1924
|
+
models.push({
|
|
1925
|
+
model_id: modelId,
|
|
1926
|
+
display_name: flags.model_name || modelId,
|
|
1927
|
+
metadata: apiBase ? { api_base: apiBase } : undefined,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const result = await client.installProvider(providerId, displayName, models);
|
|
1932
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1933
|
+
return result;
|
|
1934
|
+
}
|