@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.
Files changed (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -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 { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
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 { effectiveGoogleMode } from "../providers/registry";
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(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials>;
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 (!isTerminalRefreshError(error)) throw error;
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
- try {
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` (key) or
472
- * `Authorization: Bearer` + the OAuth beta (oauth) not a bare Bearer. Google (ai-studio mode)
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
- prov,
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`, headers };
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["x-api-key"] = apiKey;
528
+ if (effectiveProvider.apiKeyTransport === "bearer") headers["Authorization"] = `Bearer ${apiKey}`;
529
+ else headers["x-api-key"] = apiKey;
501
530
  }
502
- return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
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`, headers };
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
- /** Add/refresh an OAuth provider's config entry on a config object (does not persist). */
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
- config.providers[provider] = { ...def.providerConfig };
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(provider: string, ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials> {
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
- if (opts?.reauthAccountId) {
584
- const existing = getAccountCredential(provider, opts.reauthAccountId);
585
- if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`);
586
- const expected = existing.accountId ?? existing.email;
587
- const got = cred.accountId ?? cred.email;
588
- if (!expected) {
589
- throw new Error("Could not verify signed-in account identity for reauth.");
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 (!got || expected !== got) {
592
- throw new Error("Signed-in account does not match the selected account. Sign in with the same account.");
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(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> {
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
- // Background: runLogin persists the credential + upserts the provider entry to disk config.
772
- runLogin(provider, ctrl, opts)
773
- .then(() => {
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
- .catch((e: unknown) => {
782
- loginAbort.delete(provider);
783
- clearManualCodeSlot(provider);
784
- const msg = e instanceof Error ? e.message : String(e);
785
- loginState.set(provider, { done: true, error: msg });
786
- if (!urlResolved) reject(e);
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"` (`x-api-key`).
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(provider: KeyLoginProvider, key: string): Promise<boolean | "unknown"> {
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 res = await fetch(`${provider.baseUrl}/models`, {
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;