@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 +8 -1
- package/bin/ocx.mjs +47 -22
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +64 -7
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/alias.ts +94 -14
- package/src/claude/inbound.ts +26 -9
- package/src/claude/outbound.ts +6 -3
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +35 -10
- package/src/cli/doctor.ts +71 -19
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +93 -19
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +79 -18
- package/src/cli/status.ts +47 -13
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-store.ts +2 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +42 -2
- package/src/codex/catalog/provider-fetch.ts +264 -70
- package/src/codex/catalog/sync.ts +45 -8
- package/src/codex/catalog.ts +2 -2
- package/src/codex/features.ts +524 -5
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +92 -2
- package/src/codex/routing.ts +695 -106
- package/src/codex/runtime.ts +10 -1
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +574 -25
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +132 -7
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +167 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +310 -75
- package/src/oauth/key-providers.ts +38 -8
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +373 -12
- package/src/oauth/kiro.ts +424 -43
- package/src/oauth/login-cli.ts +33 -6
- package/src/oauth/store.ts +56 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +8 -2
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +125 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +19 -7
- package/src/server/auth-cors.ts +114 -24
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +106 -8
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +22 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +73 -10
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +5 -3
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +614 -172
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +284 -19
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +387 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +253 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +34 -4
- package/src/types.ts +107 -1
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +111 -16
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +12 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-CjKFJHSC.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/oauth/index.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
import type { OAuthController, OAuthCredentials } from "./types";
|
|
1
|
+
import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
|
|
2
2
|
import { parseCallbackInput } from "./callback-server";
|
|
3
3
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
4
4
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
5
5
|
import { maskEmail } from "../lib/privacy";
|
|
6
|
-
import {
|
|
6
|
+
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
|
|
7
|
+
import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
|
|
7
8
|
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
|
|
8
9
|
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
9
10
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
10
|
-
import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
|
|
11
11
|
import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
|
|
12
12
|
import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
|
|
13
13
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
14
14
|
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
|
|
15
15
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
16
|
-
import {
|
|
16
|
+
import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
|
|
17
|
+
import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry";
|
|
18
|
+
import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
17
19
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
18
20
|
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
|
|
19
21
|
import { logOAuthEvent } from "./log";
|
|
@@ -37,6 +39,7 @@ export {
|
|
|
37
39
|
type OAuthHealthLabel,
|
|
38
40
|
} from "./health";
|
|
39
41
|
export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store";
|
|
42
|
+
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
|
|
40
43
|
|
|
41
44
|
const REFRESH_SKEW_MS = 60_000;
|
|
42
45
|
export interface OAuthAccessSnapshot {
|
|
@@ -44,6 +47,8 @@ export interface OAuthAccessSnapshot {
|
|
|
44
47
|
accountId: string;
|
|
45
48
|
generation: string;
|
|
46
49
|
accessToken: string;
|
|
50
|
+
/** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
|
|
51
|
+
kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
const tokenRefreshes = new Map<string, Promise<OAuthAccessSnapshot>>();
|
|
@@ -57,9 +62,18 @@ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=ver
|
|
|
57
62
|
|
|
58
63
|
export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string }
|
|
59
64
|
|
|
65
|
+
export interface LoginFlowLifecycle {
|
|
66
|
+
/** Runs after background credential/config persistence settles, before status becomes done. */
|
|
67
|
+
onSettled?: () => void | Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
interface OAuthProviderDef {
|
|
61
71
|
login(ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials>;
|
|
62
|
-
refresh(
|
|
72
|
+
refresh(
|
|
73
|
+
refreshToken: string,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
credential?: OAuthCredentials,
|
|
76
|
+
): Promise<OAuthCredentials>;
|
|
63
77
|
/** provider entry written into config.json on first login. */
|
|
64
78
|
providerConfig: OcxProviderConfig;
|
|
65
79
|
defaultModel: string;
|
|
@@ -107,8 +121,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
107
121
|
defaultModel: oauthDefaultModel("kimi"),
|
|
108
122
|
},
|
|
109
123
|
kiro: {
|
|
110
|
-
login: (ctrl) => loginKiro(ctrl),
|
|
111
|
-
refresh: (rt, signal) => refreshKiroToken(rt, signal),
|
|
124
|
+
login: (ctrl, opts) => loginKiro(ctrl, { forceLogin: opts?.forceLogin }),
|
|
125
|
+
refresh: (rt, signal, credential) => refreshKiroToken(rt, signal, credential),
|
|
112
126
|
providerConfig: oauthConfig("kiro"),
|
|
113
127
|
defaultModel: oauthDefaultModel("kiro"),
|
|
114
128
|
},
|
|
@@ -194,11 +208,25 @@ export class OAuthLoginRequiredError extends Error {
|
|
|
194
208
|
}
|
|
195
209
|
|
|
196
210
|
function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
|
|
211
|
+
const storedKiroRouting = {
|
|
212
|
+
...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}),
|
|
213
|
+
...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}),
|
|
214
|
+
...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}),
|
|
215
|
+
};
|
|
197
216
|
return {
|
|
198
217
|
provider,
|
|
199
218
|
accountId,
|
|
200
219
|
generation: credentialGeneration(cred),
|
|
201
220
|
accessToken: cred.access,
|
|
221
|
+
// Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
|
|
222
|
+
// may use explicit environment routing, but never borrow the currently signed-in local CLI account.
|
|
223
|
+
...(provider === "kiro"
|
|
224
|
+
? {
|
|
225
|
+
kiro: Object.keys(storedKiroRouting).length > 0
|
|
226
|
+
? storedKiroRouting
|
|
227
|
+
: environmentKiroRoutingMetadata() ?? {},
|
|
228
|
+
}
|
|
229
|
+
: {}),
|
|
202
230
|
};
|
|
203
231
|
}
|
|
204
232
|
|
|
@@ -267,12 +295,6 @@ export async function getValidAccessTokenForAccount(provider: string, accountId:
|
|
|
267
295
|
return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken;
|
|
268
296
|
}
|
|
269
297
|
|
|
270
|
-
function readFreshKiroCliCredential(): OAuthCredentials | undefined {
|
|
271
|
-
const imported = readKiroCliSqlite();
|
|
272
|
-
if (!imported || imported.expires <= Date.now() + REFRESH_SKEW_MS) return undefined;
|
|
273
|
-
return { access: imported.access, refresh: imported.refresh, expires: imported.expires, source: "local-cli" };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
298
|
/** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
|
|
277
299
|
function isTerminalRefreshError(err: unknown): boolean {
|
|
278
300
|
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
@@ -286,6 +308,7 @@ function isTerminalRefreshError(err: unknown): boolean {
|
|
|
286
308
|
function terminal(error:unknown):boolean{
|
|
287
309
|
if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??"");
|
|
288
310
|
if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
|
|
311
|
+
if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined;
|
|
289
312
|
return isTerminalRefreshError(error);
|
|
290
313
|
}
|
|
291
314
|
function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
|
|
@@ -297,6 +320,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
|
|
|
297
320
|
...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}),
|
|
298
321
|
...(fresh.email === undefined && previous.email ? { email: previous.email } : {}),
|
|
299
322
|
...(fresh.accountId === undefined && previous.accountId ? { accountId: previous.accountId } : {}),
|
|
323
|
+
...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}),
|
|
300
324
|
};
|
|
301
325
|
}
|
|
302
326
|
export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
|
|
@@ -395,7 +419,7 @@ export async function refreshGenericAccountWithLock(
|
|
|
395
419
|
}
|
|
396
420
|
const generation = credentialGeneration(stored);
|
|
397
421
|
try {
|
|
398
|
-
const fresh = merged(await def.refresh(stored.refresh), stored);
|
|
422
|
+
const fresh = merged(await def.refresh(stored.refresh, undefined, stored), stored);
|
|
399
423
|
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
|
|
400
424
|
expectedGeneration: generation,
|
|
401
425
|
afterPrePersistRead: deps.afterPrePersistRead,
|
|
@@ -407,7 +431,7 @@ export async function refreshGenericAccountWithLock(
|
|
|
407
431
|
logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId });
|
|
408
432
|
return fresh.access;
|
|
409
433
|
} catch (error) {
|
|
410
|
-
if (!
|
|
434
|
+
if (!terminal(error)) throw error;
|
|
411
435
|
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
|
|
412
436
|
throw new OAuthLoginRequiredError(provider);
|
|
413
437
|
}
|
|
@@ -422,30 +446,9 @@ async function refreshAndPersistAccessToken(
|
|
|
422
446
|
def: OAuthProviderDef,
|
|
423
447
|
cred: OAuthCredentials,
|
|
424
448
|
): Promise<string> {
|
|
425
|
-
// Local-CLI import fallback only for the ACTIVE account: importing another identity's
|
|
426
|
-
// token under a background account id would silently contaminate that account.
|
|
427
|
-
const isActive = getAccountSet(provider)?.activeAccountId === accountId;
|
|
428
|
-
if (provider === "kiro" && isActive) {
|
|
429
|
-
const imported = readFreshKiroCliCredential();
|
|
430
|
-
if (imported) {
|
|
431
|
-
await saveCredential(provider, imported);
|
|
432
|
-
return imported.access;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
449
|
if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
|
|
436
450
|
if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred);
|
|
437
|
-
|
|
438
|
-
return await refreshGenericAccountWithLock(provider, accountId, def, cred);
|
|
439
|
-
} catch (err) {
|
|
440
|
-
if (provider === "kiro" && isActive) {
|
|
441
|
-
const imported = readFreshKiroCliCredential();
|
|
442
|
-
if (imported) {
|
|
443
|
-
await saveCredential(provider, imported);
|
|
444
|
-
return imported.access;
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
throw err;
|
|
448
|
-
}
|
|
451
|
+
return refreshGenericAccountWithLock(provider, accountId, def, cred);
|
|
449
452
|
}
|
|
450
453
|
|
|
451
454
|
/**
|
|
@@ -465,44 +468,70 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
|
|
|
465
468
|
return resolveEnvValue(prov.apiKey);
|
|
466
469
|
}
|
|
467
470
|
|
|
471
|
+
function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConfig): OcxProviderConfig {
|
|
472
|
+
const entry = getProviderRegistryEntry(providerName);
|
|
473
|
+
if (
|
|
474
|
+
prov.authMode !== "oauth"
|
|
475
|
+
|| entry?.authKind !== "oauth"
|
|
476
|
+
|| entry.allowBaseUrlOverride === true
|
|
477
|
+
|| /\{[^}]*\}/.test(entry.baseUrl)
|
|
478
|
+
|| !providerMatchesRegistryTransport(providerName, prov)
|
|
479
|
+
) {
|
|
480
|
+
return prov;
|
|
481
|
+
}
|
|
482
|
+
// Normal routing pins fixed OAuth presets before adapter-specific transport resolution.
|
|
483
|
+
// Discovery must do the same so a stale or modified config baseUrl never receives a token.
|
|
484
|
+
return { ...prov, adapter: entry.adapter, baseUrl: entry.baseUrl };
|
|
485
|
+
}
|
|
486
|
+
|
|
468
487
|
/**
|
|
469
488
|
* Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the
|
|
470
489
|
* LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models`
|
|
471
|
-
* (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key`
|
|
472
|
-
* `Authorization: Bearer`
|
|
490
|
+
* (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` by default
|
|
491
|
+
* (or `Authorization: Bearer` when `apiKeyTransport = "bearer"`), plus the OAuth beta for oauth
|
|
492
|
+
* mode — not a bare Bearer. Google (ai-studio mode)
|
|
473
493
|
* is the other special case: `x-goog-api-key` + `/v1beta/models`, returning `{ models: [...] }`.
|
|
474
494
|
* The catalog authority gate intentionally degrades that non-OpenAI shape to stale/static data.
|
|
475
495
|
* Everyone else uses the OpenAI-style `/models` + Bearer with a `{ data: [{ id, owned_by? }] }`
|
|
476
496
|
* response.
|
|
477
497
|
*/
|
|
478
498
|
export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
|
|
499
|
+
const transportSeed = modelDiscoveryTransportSeed(providerName, prov);
|
|
479
500
|
const effectiveProvider = resolveProviderTransport(
|
|
480
501
|
providerName,
|
|
481
|
-
|
|
502
|
+
transportSeed,
|
|
482
503
|
undefined,
|
|
483
504
|
providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined,
|
|
484
505
|
);
|
|
485
506
|
const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
|
|
507
|
+
const discoveryUrl = (defaultUrl: string): string => resolveProviderModelDiscoveryUrl(
|
|
508
|
+
providerName,
|
|
509
|
+
prov,
|
|
510
|
+
effectiveProvider.baseUrl,
|
|
511
|
+
defaultUrl,
|
|
512
|
+
);
|
|
486
513
|
if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
|
|
487
514
|
// Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
|
|
488
515
|
// models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
|
|
489
516
|
// enough to list everything without a pageToken loop. Vertex/antigravity keep the
|
|
490
517
|
// generic branch (they fall back to their static model lists).
|
|
491
518
|
if (apiKey) headers["x-goog-api-key"] = apiKey;
|
|
492
|
-
return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000
|
|
519
|
+
return { url: discoveryUrl(`${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`), headers };
|
|
493
520
|
}
|
|
494
521
|
if (effectiveProvider.adapter === "anthropic") {
|
|
522
|
+
const base = effectiveProvider.baseUrl.replace(/\/v1\/?$/, "");
|
|
495
523
|
headers["anthropic-version"] = "2023-06-01";
|
|
496
524
|
if (effectiveProvider.authMode === "oauth") {
|
|
497
525
|
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
|
|
498
526
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
499
527
|
} else if (apiKey) {
|
|
500
|
-
headers["
|
|
528
|
+
if (effectiveProvider.apiKeyTransport === "bearer") headers["Authorization"] = `Bearer ${apiKey}`;
|
|
529
|
+
else headers["x-api-key"] = apiKey;
|
|
501
530
|
}
|
|
502
|
-
return { url: `${
|
|
531
|
+
return { url: discoveryUrl(`${base}/v1/models?limit=1000`), headers };
|
|
503
532
|
}
|
|
504
533
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
505
|
-
return { url: `${effectiveProvider.baseUrl}/models
|
|
534
|
+
return { url: discoveryUrl(`${effectiveProvider.baseUrl}/models`), headers };
|
|
506
535
|
}
|
|
507
536
|
|
|
508
537
|
/**
|
|
@@ -566,39 +595,219 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
|
|
|
566
595
|
return changed;
|
|
567
596
|
}
|
|
568
597
|
|
|
569
|
-
/**
|
|
598
|
+
/** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
|
|
599
|
+
function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
|
|
600
|
+
if (!Array.isArray(value)) return undefined;
|
|
601
|
+
const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
|
|
602
|
+
const ids = new Set<string>();
|
|
603
|
+
const keys = new Set<string>();
|
|
604
|
+
for (const entry of value as unknown[]) {
|
|
605
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
606
|
+
const candidate = entry as Record<string, unknown>;
|
|
607
|
+
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
|
608
|
+
const key = sanitizeApiKeyValue(candidate.key);
|
|
609
|
+
if (!id || !key || ids.has(id) || keys.has(key)) continue;
|
|
610
|
+
const label = typeof candidate.label === "string" ? candidate.label : undefined;
|
|
611
|
+
const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
|
|
612
|
+
? candidate.addedAt
|
|
613
|
+
: undefined;
|
|
614
|
+
ids.add(id);
|
|
615
|
+
keys.add(key);
|
|
616
|
+
pool.push({
|
|
617
|
+
id,
|
|
618
|
+
key,
|
|
619
|
+
...(label !== undefined ? { label } : {}),
|
|
620
|
+
...(addedAt !== undefined ? { addedAt } : {}),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
// `apiKey` remains the routing source of truth. Keep valid alternate slots even when a
|
|
624
|
+
// hand-edited config left the pool out of sync, rather than deleting usable credentials.
|
|
625
|
+
return pool.length > 0 ? pool : undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Add/refresh an OAuth provider's config entry on a config object (does not persist).
|
|
630
|
+
*
|
|
631
|
+
* Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be
|
|
632
|
+
* billed through a stored API key instead of the OAuth login (router.ts honors
|
|
633
|
+
* `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool`
|
|
634
|
+
* on every OAuth login, silently destroying the stored key and forcing a re-paste — and it
|
|
635
|
+
* flips billing back to the subscription without the user asking. Carry the key fields over
|
|
636
|
+
* and keep key billing while usable key material remains and the user was not explicitly on
|
|
637
|
+
* oauth. If the final key was removed and only the old key mode remains, let the OAuth
|
|
638
|
+
* preset restore `authMode: "oauth"` so the newly saved OAuth credential can be used.
|
|
639
|
+
*
|
|
640
|
+
* After preservation, `apiKey` always has exactly one matching pool entry (inserting via the
|
|
641
|
+
* same content-derived id as the API-key manager when the active key was missing from the
|
|
642
|
+
* pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe
|
|
643
|
+
* key material) — never whether the login CLI process can resolve an env reference. Env-backed
|
|
644
|
+
* availability is decided at proxy routing time in `router.ts`.
|
|
645
|
+
*/
|
|
570
646
|
export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
571
647
|
if (provider === "chatgpt") return;
|
|
572
648
|
const def = OAUTH_PROVIDERS[provider];
|
|
573
649
|
if (!def) return;
|
|
574
|
-
|
|
650
|
+
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
|
|
651
|
+
if (namespaceCollision) throw new Error(namespaceCollision);
|
|
652
|
+
const existing = config.providers[provider];
|
|
653
|
+
const next: OcxProviderConfig = { ...def.providerConfig };
|
|
654
|
+
if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
|
|
655
|
+
// Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
|
|
656
|
+
let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
|
|
657
|
+
const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
|
|
658
|
+
// Unsafe/blank active key with a usable pool: promote the first safe pool entry so
|
|
659
|
+
// key billing keeps working instead of falling back to oauth while pool keys remain.
|
|
660
|
+
if (storedApiKey === undefined && storedApiKeyPool && storedApiKeyPool.length > 0) {
|
|
661
|
+
storedApiKey = storedApiKeyPool[0]!.key;
|
|
662
|
+
}
|
|
663
|
+
if (storedApiKey !== undefined) {
|
|
664
|
+
const pool = storedApiKeyPool ? [...storedApiKeyPool] : [];
|
|
665
|
+
// Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
|
|
666
|
+
// is absent from the pool (listing would fall back to pool[0] as "active").
|
|
667
|
+
if (!pool.some(entry => entry.key === storedApiKey)) {
|
|
668
|
+
pool.push({ id: apiKeyPoolEntryId(storedApiKey), key: storedApiKey });
|
|
669
|
+
}
|
|
670
|
+
next.apiKey = storedApiKey;
|
|
671
|
+
next.apiKeyPool = pool;
|
|
672
|
+
const previousModeAllowsKey = existing.authMode === "key" || existing.authMode === undefined;
|
|
673
|
+
if (previousModeAllowsKey) next.authMode = "key";
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
config.providers[provider] = next;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
interface RunLoginDeps {
|
|
680
|
+
saveCredential?: typeof saveCredential;
|
|
681
|
+
saveAccountCredential?: typeof saveAccountCredential;
|
|
682
|
+
loadConfig?: typeof loadConfig;
|
|
683
|
+
saveConfig?: typeof saveConfig;
|
|
684
|
+
settleKiroLoginTransaction?: typeof settleKiroLoginTransaction;
|
|
685
|
+
removeAccount?: typeof removeAccount;
|
|
686
|
+
setActiveAccount?: typeof setActiveAccount;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Roll back only accounts created by this forced login, preserving concurrent refreshes of others. */
|
|
690
|
+
async function rollbackForcedKiroAccountWrite(
|
|
691
|
+
provider: string,
|
|
692
|
+
previousActiveId: string | undefined,
|
|
693
|
+
previousAccountIds: ReadonlySet<string>,
|
|
694
|
+
deps: Pick<RunLoginDeps, "removeAccount" | "setActiveAccount">,
|
|
695
|
+
): Promise<void> {
|
|
696
|
+
const set = getAccountSet(provider);
|
|
697
|
+
if (!set) return;
|
|
698
|
+
for (const account of [...set.accounts]) {
|
|
699
|
+
if (previousAccountIds.has(account.id)) continue;
|
|
700
|
+
await (deps.removeAccount ?? removeAccount)(provider, account.id);
|
|
701
|
+
}
|
|
702
|
+
if (previousActiveId && getAccountCredential(provider, previousActiveId)) {
|
|
703
|
+
await (deps.setActiveAccount ?? setActiveAccount)(provider, previousActiveId);
|
|
704
|
+
}
|
|
575
705
|
}
|
|
576
706
|
|
|
577
707
|
/** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */
|
|
578
|
-
export async function runLogin(
|
|
708
|
+
export async function runLogin(
|
|
709
|
+
provider: string,
|
|
710
|
+
ctrl: OAuthController,
|
|
711
|
+
opts?: LoginOpts,
|
|
712
|
+
deps: RunLoginDeps = {},
|
|
713
|
+
): Promise<OAuthCredentials> {
|
|
579
714
|
const def = OAUTH_PROVIDERS[provider];
|
|
580
715
|
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
716
|
+
const loadLatestConfig = deps.loadConfig ?? loadConfig;
|
|
717
|
+
const saveLatestConfig = deps.saveConfig ?? saveConfig;
|
|
718
|
+
if (provider !== "chatgpt") {
|
|
719
|
+
const preflightConfig = loadLatestConfig();
|
|
720
|
+
const namespaceCollision = codexAccountNamespaceProviderCollisionError(
|
|
721
|
+
preflightConfig.codexAccountNamespaces,
|
|
722
|
+
provider,
|
|
723
|
+
);
|
|
724
|
+
if (namespaceCollision) throw new Error(namespaceCollision);
|
|
725
|
+
}
|
|
726
|
+
// loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object
|
|
727
|
+
// for settlement even when source normalization below creates a derived credential object.
|
|
728
|
+
const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true;
|
|
729
|
+
const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined;
|
|
730
|
+
const previousKiroActiveId = previousKiroAccounts?.activeAccountId;
|
|
731
|
+
const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []);
|
|
581
732
|
const rawCred = await def.login(ctrl, opts);
|
|
582
733
|
const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
734
|
+
const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
|
|
735
|
+
try {
|
|
736
|
+
// Validate the provider row before credential persistence. A namespace claimed during the
|
|
737
|
+
// credential write is handled again below before the latest row is re-upserted.
|
|
738
|
+
if (provider !== "chatgpt") {
|
|
739
|
+
const preCommitConfig = loadLatestConfig();
|
|
740
|
+
upsertOAuthProvider(preCommitConfig, provider);
|
|
741
|
+
}
|
|
742
|
+
if (opts?.reauthAccountId) {
|
|
743
|
+
const existing = getAccountCredential(provider, opts.reauthAccountId);
|
|
744
|
+
if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`);
|
|
745
|
+
if (!existing.accountId && !existing.email) {
|
|
746
|
+
throw new Error("Could not verify signed-in account identity for reauth.");
|
|
747
|
+
}
|
|
748
|
+
const identityMatches = existing.accountId && cred.accountId
|
|
749
|
+
? existing.accountId === cred.accountId
|
|
750
|
+
: existing.email && cred.email
|
|
751
|
+
? existing.email.toLowerCase() === cred.email.toLowerCase()
|
|
752
|
+
: false;
|
|
753
|
+
if (!identityMatches) {
|
|
754
|
+
throw new Error("Signed-in account does not match the selected account. Sign in with the same account.");
|
|
755
|
+
}
|
|
756
|
+
await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred);
|
|
757
|
+
} else {
|
|
758
|
+
await (deps.saveCredential ?? saveCredential)(provider, cred, {
|
|
759
|
+
preserveIdentityless: provider === "kiro" && opts?.forceLogin === true,
|
|
760
|
+
});
|
|
590
761
|
}
|
|
591
|
-
if (
|
|
592
|
-
|
|
762
|
+
if (provider !== "chatgpt") {
|
|
763
|
+
// Re-run against post-credential state so same-provider API-key additions, removals,
|
|
764
|
+
// and active-key switches survive. A late namespace claim wins over provider creation.
|
|
765
|
+
const latestConfig = loadLatestConfig();
|
|
766
|
+
const lateCollision = codexAccountNamespaceProviderCollisionError(
|
|
767
|
+
latestConfig.codexAccountNamespaces,
|
|
768
|
+
provider,
|
|
769
|
+
);
|
|
770
|
+
if (lateCollision) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. `
|
|
773
|
+
+ "Rename the account selector, then re-run the login.",
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
upsertOAuthProvider(latestConfig, provider);
|
|
777
|
+
saveLatestConfig(latestConfig);
|
|
778
|
+
}
|
|
779
|
+
} catch (error) {
|
|
780
|
+
const errors: unknown[] = [error];
|
|
781
|
+
if (shouldRollbackKiroAccounts) {
|
|
782
|
+
try {
|
|
783
|
+
await rollbackForcedKiroAccountWrite(provider, previousKiroActiveId, previousKiroAccountIds, deps);
|
|
784
|
+
} catch (rollbackError) {
|
|
785
|
+
errors.push(rollbackError);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
settleKiroTransaction(rawCred, false);
|
|
790
|
+
} catch (restoreError) {
|
|
791
|
+
errors.push(restoreError);
|
|
792
|
+
}
|
|
793
|
+
if (errors.length > 1) {
|
|
794
|
+
throw new AggregateError(
|
|
795
|
+
errors,
|
|
796
|
+
"Kiro login persistence failed and the previous Kiro CLI session could not be restored.",
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
801
|
+
settleKiroTransaction(rawCred, true);
|
|
802
|
+
if (provider !== "chatgpt") {
|
|
803
|
+
try {
|
|
804
|
+
const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota");
|
|
805
|
+
clearProviderQuotaCache();
|
|
806
|
+
clearAccountQuotaCache(provider);
|
|
807
|
+
} catch {
|
|
808
|
+
// Quota module may be unavailable in tightly scoped unit tests.
|
|
593
809
|
}
|
|
594
|
-
await saveAccountCredential(provider, opts.reauthAccountId, cred);
|
|
595
|
-
} else {
|
|
596
|
-
await saveCredential(provider, cred);
|
|
597
810
|
}
|
|
598
|
-
if (provider === "chatgpt") return cred;
|
|
599
|
-
const config = loadConfig();
|
|
600
|
-
upsertOAuthProvider(config, provider);
|
|
601
|
-
saveConfig(config);
|
|
602
811
|
return cred;
|
|
603
812
|
}
|
|
604
813
|
|
|
@@ -745,7 +954,11 @@ export function cancelLoginFlow(provider: string): boolean {
|
|
|
745
954
|
return true;
|
|
746
955
|
}
|
|
747
956
|
|
|
748
|
-
export async function startLoginFlow(
|
|
957
|
+
export async function startLoginFlow(
|
|
958
|
+
provider: string,
|
|
959
|
+
opts?: LoginOpts,
|
|
960
|
+
lifecycle?: LoginFlowLifecycle,
|
|
961
|
+
): Promise<{ url: string; instructions?: string; deviceCode?: string }> {
|
|
749
962
|
const def = OAUTH_PROVIDERS[provider];
|
|
750
963
|
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
751
964
|
const existing = loginState.get(provider);
|
|
@@ -768,22 +981,44 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
|
|
|
768
981
|
onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState),
|
|
769
982
|
signal: abort.signal,
|
|
770
983
|
};
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
984
|
+
const settle = async (error?: unknown): Promise<void> => {
|
|
985
|
+
let finalError = error;
|
|
986
|
+
try {
|
|
987
|
+
await lifecycle?.onSettled?.();
|
|
988
|
+
} catch (settleError) {
|
|
989
|
+
// A successful credential/config commit is not fully live until its owner reconciles the
|
|
990
|
+
// runtime config. For an already-failed login, keep the original recovery error.
|
|
991
|
+
if (finalError === undefined) finalError = settleError;
|
|
992
|
+
}
|
|
993
|
+
if (finalError === undefined) {
|
|
774
994
|
loginAbort.delete(provider);
|
|
775
995
|
clearManualCodeSlot(provider);
|
|
776
996
|
loginState.set(provider, { done: true });
|
|
777
997
|
// Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
|
|
778
998
|
// resolve so the GUI call returns instead of hanging.
|
|
779
999
|
if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." });
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
});
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const e = finalError;
|
|
1004
|
+
loginAbort.delete(provider);
|
|
1005
|
+
clearManualCodeSlot(provider);
|
|
1006
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1007
|
+
loginState.set(provider, { done: true, error: msg });
|
|
1008
|
+
if (!urlResolved) reject(e);
|
|
1009
|
+
};
|
|
1010
|
+
// Background: runLogin persists the credential + provider entry to disk. The lifecycle hook
|
|
1011
|
+
// lets a long-lived server config adopt that settled state before clients observe done=true.
|
|
1012
|
+
void runLogin(provider, ctrl, opts).then(
|
|
1013
|
+
() => settle(),
|
|
1014
|
+
(e: unknown) => settle(e),
|
|
1015
|
+
).catch((e: unknown) => {
|
|
1016
|
+
// settle catches lifecycle failures, so this is only a defensive promise-boundary guard.
|
|
1017
|
+
loginAbort.delete(provider);
|
|
1018
|
+
clearManualCodeSlot(provider);
|
|
1019
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1020
|
+
loginState.set(provider, { done: true, error: msg });
|
|
1021
|
+
if (!urlResolved) reject(e);
|
|
1022
|
+
});
|
|
788
1023
|
});
|
|
789
1024
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { OcxProviderConfig } from "../types";
|
|
2
2
|
import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive";
|
|
3
|
+
import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
|
|
6
7
|
* create/copy a key, then validates + stores it as the provider's `apiKey` (authMode "key").
|
|
7
8
|
* Most use the OpenAI-compatible chat API (`openai-chat` adapter, `Authorization: Bearer <key>`); a
|
|
8
|
-
* few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (
|
|
9
|
+
* few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (default
|
|
10
|
+
* `x-api-key`, optional bearer via `apiKeyTransport`).
|
|
9
11
|
*/
|
|
10
12
|
export interface KeyLoginProvider extends DerivedKeyLoginProvider {}
|
|
11
13
|
|
|
@@ -29,23 +31,38 @@ export function listKeyLoginProviders(): Array<{ id: string } & KeyLoginProvider
|
|
|
29
31
|
return Object.entries(KEY_LOGIN_PROVIDERS).map(([id, p]) => ({ id, ...p }));
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function anthropicKeyValidationHeaders(provider: Pick<KeyLoginProvider, "apiKeyTransport">, key: string): HeadersInit {
|
|
35
|
+
return provider.apiKeyTransport === "bearer"
|
|
36
|
+
? {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
"anthropic-version": "2023-06-01",
|
|
39
|
+
"Authorization": `Bearer ${key}`,
|
|
40
|
+
}
|
|
41
|
+
: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"anthropic-version": "2023-06-01",
|
|
44
|
+
"x-api-key": key,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/** Best-effort key validation. Returns true/false/unknown; never persists the key itself. */
|
|
33
|
-
export async function validateApiKey(
|
|
49
|
+
export async function validateApiKey(
|
|
50
|
+
providerName: string,
|
|
51
|
+
provider: KeyLoginProvider,
|
|
52
|
+
key: string,
|
|
53
|
+
): Promise<boolean | "unknown"> {
|
|
34
54
|
try {
|
|
35
55
|
if (provider.adapter === "anthropic") {
|
|
36
56
|
const base = provider.baseUrl.replace(/\/v1\/?$/, "");
|
|
37
57
|
const res = await fetch(`${base}/v1/messages`, {
|
|
38
58
|
method: "POST",
|
|
39
|
-
headers:
|
|
40
|
-
"Content-Type": "application/json",
|
|
41
|
-
"anthropic-version": "2023-06-01",
|
|
42
|
-
"x-api-key": key,
|
|
43
|
-
},
|
|
59
|
+
headers: anthropicKeyValidationHeaders(provider, key),
|
|
44
60
|
body: JSON.stringify({
|
|
45
61
|
model: provider.defaultModel ?? "claude-haiku-4-5",
|
|
46
62
|
max_tokens: 1,
|
|
47
63
|
messages: [{ role: "user", content: "ping" }],
|
|
48
64
|
}),
|
|
65
|
+
redirect: "error",
|
|
49
66
|
signal: AbortSignal.timeout(8000),
|
|
50
67
|
});
|
|
51
68
|
if (res.ok) return true;
|
|
@@ -58,6 +75,7 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P
|
|
|
58
75
|
// documented x-goog-api-key header instead (pageSize=1 — validation only needs a 200).
|
|
59
76
|
const res = await fetch(`${provider.baseUrl}/v1beta/models?pageSize=1`, {
|
|
60
77
|
headers: { "x-goog-api-key": key },
|
|
78
|
+
redirect: "error",
|
|
61
79
|
signal: AbortSignal.timeout(8000),
|
|
62
80
|
});
|
|
63
81
|
if (res.ok) return true;
|
|
@@ -65,8 +83,20 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P
|
|
|
65
83
|
return "unknown";
|
|
66
84
|
}
|
|
67
85
|
|
|
68
|
-
const
|
|
86
|
+
const configuredProvider: OcxProviderConfig = {
|
|
87
|
+
adapter: provider.adapter,
|
|
88
|
+
baseUrl: provider.baseUrl,
|
|
89
|
+
authMode: "key",
|
|
90
|
+
};
|
|
91
|
+
const modelsUrl = resolveProviderModelDiscoveryUrl(
|
|
92
|
+
providerName,
|
|
93
|
+
configuredProvider,
|
|
94
|
+
provider.baseUrl,
|
|
95
|
+
`${provider.baseUrl}/models`,
|
|
96
|
+
);
|
|
97
|
+
const res = await fetch(modelsUrl, {
|
|
69
98
|
headers: { Authorization: `Bearer ${key}` },
|
|
99
|
+
redirect: "error",
|
|
70
100
|
signal: AbortSignal.timeout(8000),
|
|
71
101
|
});
|
|
72
102
|
if (res.ok) return true;
|