ethagent 2.1.1 → 2.3.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 (177) hide show
  1. package/package.json +2 -1
  2. package/src/app/FirstRun.tsx +1 -7
  3. package/src/app/FirstRunTimeline.tsx +1 -1
  4. package/src/auth/openaiOAuth/credentials.ts +47 -0
  5. package/src/auth/openaiOAuth/crypto.ts +23 -0
  6. package/src/auth/openaiOAuth/index.ts +238 -0
  7. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  8. package/src/auth/openaiOAuth/listener.ts +151 -0
  9. package/src/auth/openaiOAuth/refresh.ts +70 -0
  10. package/src/auth/openaiOAuth/shared.ts +115 -0
  11. package/src/chat/ChatBottomPane.tsx +20 -11
  12. package/src/chat/ChatScreen.tsx +160 -35
  13. package/src/chat/ConversationStack.tsx +1 -1
  14. package/src/chat/MessageList.tsx +185 -72
  15. package/src/chat/SessionStatus.tsx +3 -1
  16. package/src/chat/chatScreenUtils.ts +11 -15
  17. package/src/chat/chatSessionState.ts +3 -2
  18. package/src/chat/chatTurnOrchestrator.ts +1 -7
  19. package/src/chat/commands.ts +28 -27
  20. package/src/chat/display/DiffView.tsx +193 -0
  21. package/src/chat/display/SyntaxText.tsx +192 -0
  22. package/src/chat/display/toolCallDisplay.ts +103 -0
  23. package/src/chat/display/toolResultDisplay.ts +19 -0
  24. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +36 -23
  25. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  26. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  27. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  28. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  29. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  30. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  31. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  32. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  33. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +35 -35
  34. package/src/chat/views/RewindView.tsx +410 -0
  35. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  36. package/src/identity/ens/agentRecords.ts +5 -19
  37. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  38. package/src/identity/ens/ensAutomation/types.ts +0 -1
  39. package/src/identity/hub/OperationalRoutes.tsx +23 -32
  40. package/src/identity/hub/Routes.tsx +13 -13
  41. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  42. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  43. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  44. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  45. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +19 -19
  46. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  47. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  48. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  49. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  50. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  51. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +10 -48
  52. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +11 -9
  53. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  54. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  55. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  56. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  57. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +6 -6
  58. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  59. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  60. package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +241 -0
  61. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +27 -82
  62. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +25 -65
  63. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -30
  64. package/src/identity/hub/ens/EnsEditRunners.tsx +62 -0
  65. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +15 -14
  66. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +68 -217
  67. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +18 -11
  68. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -48
  69. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  70. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +4 -4
  71. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  72. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  73. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  74. package/src/identity/hub/{effects/ens → ens}/transactions.ts +232 -232
  75. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +12 -26
  76. package/src/identity/hub/identityHubReducer.ts +3 -3
  77. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +17 -10
  78. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +55 -177
  79. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  80. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -173
  81. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +21 -21
  82. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  83. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  84. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  85. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  86. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  87. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  88. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  89. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  90. package/src/identity/hub/restore/restoreAdmin.ts +34 -0
  91. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  92. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  93. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  94. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  95. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  96. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  97. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +16 -11
  98. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +8 -9
  99. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  100. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  101. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  102. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  103. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +2 -4
  104. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  105. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  106. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +6 -47
  107. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  108. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  109. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  110. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  111. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  112. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -2
  113. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  114. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +7 -40
  115. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -4
  116. package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -7
  117. package/src/identity/hub/shared/reconciliation/walletSetup.ts +27 -0
  118. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  119. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  120. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  121. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  122. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  123. package/src/identity/hub/useIdentityHubController.ts +11 -11
  124. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  125. package/src/identity/wallet/browserWallet/types.ts +0 -5
  126. package/src/identity/wallet/page/copy.ts +1 -31
  127. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  128. package/src/models/ModelPicker.tsx +248 -8
  129. package/src/models/catalog.ts +29 -1
  130. package/src/models/modelPickerOptions.ts +12 -10
  131. package/src/models/providerDisplay.ts +16 -0
  132. package/src/providers/errors.ts +6 -4
  133. package/src/providers/openai-chat.ts +2 -1
  134. package/src/providers/openai-responses-format.ts +156 -0
  135. package/src/providers/openai-responses.ts +276 -0
  136. package/src/providers/registry.ts +85 -8
  137. package/src/runtime/sessionMode.ts +1 -1
  138. package/src/runtime/systemPrompt.ts +4 -2
  139. package/src/runtime/toolExecution.ts +9 -6
  140. package/src/runtime/turn.ts +29 -1
  141. package/src/storage/rewind.ts +20 -0
  142. package/src/storage/secrets.ts +4 -1
  143. package/src/storage/sessions.ts +2 -1
  144. package/src/tools/bashSafety.ts +7 -3
  145. package/src/tools/bashTool.ts +1 -1
  146. package/src/tools/contracts.ts +3 -0
  147. package/src/tools/deleteFileTool.ts +8 -3
  148. package/src/tools/editTool.ts +10 -5
  149. package/src/tools/fileDiff.ts +261 -0
  150. package/src/tools/privateContinuityEditTool.ts +11 -1
  151. package/src/tools/writeFileTool.ts +8 -3
  152. package/src/ui/Spinner.tsx +25 -3
  153. package/src/ui/TextInput.tsx +2 -2
  154. package/src/ui/theme.ts +17 -0
  155. package/src/utils/clipboard.ts +10 -7
  156. package/src/utils/openExternal.ts +20 -10
  157. package/src/chat/RewindView.tsx +0 -386
  158. package/src/chat/toolResultDisplay.ts +0 -8
  159. package/src/identity/ens/ensRegistration.ts +0 -199
  160. package/src/identity/hub/effects/index.ts +0 -74
  161. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  162. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  163. package/src/identity/hub/effects/restoreAdmin.ts +0 -93
  164. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  165. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +0 -336
  166. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -198
  167. package/src/identity/hub/reconciliation/walletSetup.ts +0 -220
  168. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  169. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  170. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  171. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  172. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  173. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  174. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  175. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  176. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  177. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -25,8 +25,11 @@ import {
25
25
  type GgufMachineFit,
26
26
  } from './modelRecommendation.js'
27
27
  import { hasKey, rmKey, setKey } from '../storage/secrets.js'
28
+ import { OpenAIOAuthService } from '../auth/openaiOAuth/index.js'
29
+ import { hasOpenAIOAuthCredentials, rmOpenAIOAuthCredentials } from '../auth/openaiOAuth/credentials.js'
30
+ import { openExternalUrl } from '../utils/openExternal.js'
28
31
  import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storage/config.js'
29
- import { clearModelCatalogCache, discoverProviderModels, type ModelCatalogResult } from './catalog.js'
32
+ import { clearModelCatalogCache, discoverProviderModels, isOpenAIOAuthAllowedModel, OPENAI_OAUTH_DEFAULT_MODEL, type ModelCatalogResult } from './catalog.js'
30
33
  import { contextWindowInfo } from '../runtime/compaction.js'
31
34
  import {
32
35
  createHfDownloadPlan,
@@ -56,6 +59,7 @@ import {
56
59
  LOCAL_MODEL_LINK_HINT,
57
60
  MODEL_PICKER_CLOUD_PROVIDERS,
58
61
  orderModelsForContextFit,
62
+ type CloudCredentialKind,
59
63
  type CloudProviderId,
60
64
  type ModelPickerContextFit,
61
65
  type ModelPickerOptionsData,
@@ -89,6 +93,8 @@ type State =
89
93
  | { kind: 'catalog'; provider: CloudProviderId; data: LoadedData }
90
94
  | { kind: 'keyEntry'; provider: CloudProviderId; action: 'set' | 'edit'; data: LoadedData; submitting: boolean; error?: string }
91
95
  | { kind: 'keyManage'; provider: CloudProviderId; data: LoadedData; submitting: boolean; error?: string }
96
+ | { kind: 'oauthManage'; data: LoadedData; submitting: boolean; error?: string }
97
+ | { kind: 'oauthLogin'; data: LoadedData; phase: 'waiting' | 'exchanging' | 'error'; url?: string; message?: string }
92
98
  | { kind: 'hfInput'; data: LoadedData; error?: string }
93
99
  | { kind: 'hfLoading'; data: LoadedData; input: string }
94
100
  | { kind: 'hfFilePick'; data: LoadedData; input: string; repo: HuggingFaceRepoInfo; files: HuggingFaceSibling[] }
@@ -119,18 +125,29 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
119
125
  }) => {
120
126
  const [state, setState] = useState<State>({ kind: 'loading' })
121
127
  const hfAbortRef = useRef<AbortController | null>(null)
128
+ const oauthServiceRef = useRef<OpenAIOAuthService | null>(null)
122
129
 
123
130
  useEffect(() => {
124
131
  let cancelled = false
125
132
  void (async () => {
126
- const [llamaCpp, hfModels, machineSpec, keyEntries] = await Promise.all([
133
+ const [llamaCpp, hfModels, machineSpec, keyEntries, openaiOauth] = await Promise.all([
127
134
  probeLlamaCpp(),
128
135
  loadHfPickerModels(),
129
136
  detectSpec(),
130
137
  Promise.all(MODEL_PICKER_CLOUD_PROVIDERS.map(async p => [p, await hasKey(p)] as const)),
138
+ hasOpenAIOAuthCredentials(),
131
139
  ])
132
140
  if (cancelled) return
133
- const cloudKeys = Object.fromEntries(keyEntries) as Partial<Record<ProviderId, boolean>>
141
+ const rawCloudKeys = Object.fromEntries(keyEntries) as Partial<Record<ProviderId, boolean>>
142
+ const cloudKeys: Partial<Record<ProviderId, boolean>> = {
143
+ ...rawCloudKeys,
144
+ openai: rawCloudKeys.openai === true || openaiOauth,
145
+ }
146
+ const cloudCredentialKinds: Partial<Record<ProviderId, CloudCredentialKind>> = {}
147
+ if (openaiOauth) cloudCredentialKinds.openai = 'oauth'
148
+ else if (rawCloudKeys.openai === true) cloudCredentialKinds.openai = 'apikey'
149
+ if (rawCloudKeys.anthropic === true) cloudCredentialKinds.anthropic = 'apikey'
150
+ if (rawCloudKeys.gemini === true) cloudCredentialKinds.gemini = 'apikey'
134
151
  const catalogEntries = await Promise.all(
135
152
  MODEL_PICKER_CLOUD_PROVIDERS
136
153
  .filter(provider => cloudKeys[provider])
@@ -144,6 +161,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
144
161
  machineSpec,
145
162
  cloudKeys,
146
163
  cloudCatalogs,
164
+ cloudCredentialKinds,
147
165
  }
148
166
  if (featuredHfRepo) {
149
167
  const installedFeatured = await findInstalledHfModelForInput(featuredHfRepo)
@@ -163,6 +181,8 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
163
181
 
164
182
  useEffect(() => () => {
165
183
  hfAbortRef.current?.abort()
184
+ oauthServiceRef.current?.cleanup()
185
+ oauthServiceRef.current = null
166
186
  }, [])
167
187
 
168
188
  if (state.kind === 'loading') {
@@ -569,7 +589,45 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
569
589
  setState({ kind: 'list', data: state.data })
570
590
  return
571
591
  }
572
- void deleteKey(state, currentConfig, setState)
592
+ void deleteKey(state, currentConfig, setState, onPick, currentProvider)
593
+ }}
594
+ onCancel={() => setState({ kind: 'list', data: state.data })}
595
+ />
596
+ )}
597
+ {error ? <Text color={theme.accentError}>{error}</Text> : null}
598
+ </Surface>
599
+ )
600
+ }
601
+
602
+ if (state.kind === 'oauthManage') {
603
+ const { submitting, error } = state
604
+ return (
605
+ <Surface
606
+ title="ChatGPT Sign-in"
607
+ subtitle="Manage your ChatGPT sign-in."
608
+ footer="enter select · esc back"
609
+ >
610
+ {submitting ? (
611
+ <Spinner label="signing out..." />
612
+ ) : (
613
+ <Select
614
+ options={[
615
+ { value: 'hdr:account', label: 'Account', disabled: true, role: 'section', bold: true },
616
+ { value: 'signin', label: 'Sign in Again', indent: 2 },
617
+ { value: 'signout', label: 'Sign Out', indent: 2 },
618
+ { value: 'hdr:nav', label: 'Navigation', disabled: true, role: 'section', bold: true },
619
+ { value: 'cancel', label: 'Back', indent: 2 },
620
+ ]}
621
+ onSubmit={(value) => {
622
+ if (value === 'signin') {
623
+ void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
624
+ return
625
+ }
626
+ if (value === 'cancel') {
627
+ setState({ kind: 'list', data: state.data })
628
+ return
629
+ }
630
+ void signOutOAuth(state, currentConfig, setState, onPick, currentProvider)
573
631
  }}
574
632
  onCancel={() => setState({ kind: 'list', data: state.data })}
575
633
  />
@@ -579,6 +637,70 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
579
637
  )
580
638
  }
581
639
 
640
+ if (state.kind === 'oauthLogin') {
641
+ if (state.phase === 'error') {
642
+ return (
643
+ <Surface
644
+ title="OpenAI Sign-in Failed"
645
+ subtitle={state.message ?? 'Sign-in did not complete.'}
646
+ tone="error"
647
+ footer="enter select · esc back"
648
+ >
649
+ <Select<'retry' | 'apikey' | 'back'>
650
+ options={[
651
+ { value: 'retry', label: 'Try Again' },
652
+ { value: 'apikey', label: 'Add API Key Instead' },
653
+ { value: 'back', label: 'Back To Picker' },
654
+ ]}
655
+ onSubmit={choice => {
656
+ if (choice === 'retry') void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
657
+ else if (choice === 'apikey') setState({ kind: 'keyEntry', provider: 'openai', action: 'set', data: state.data, submitting: false })
658
+ else setState({ kind: 'list', data: state.data })
659
+ }}
660
+ onCancel={() => setState({ kind: 'list', data: state.data })}
661
+ />
662
+ </Surface>
663
+ )
664
+ }
665
+ if (state.phase === 'exchanging') {
666
+ return (
667
+ <Surface title="Finishing OpenAI Sign-in" subtitle="Exchanging credentials with auth.openai.com.">
668
+ <Spinner label="completing sign-in..." />
669
+ </Surface>
670
+ )
671
+ }
672
+ return (
673
+ <Surface
674
+ title="Sign in with ChatGPT"
675
+ subtitle="Opened your browser to auth.openai.com. Approve to continue."
676
+ footer="esc cancel"
677
+ >
678
+ <Spinner label="waiting for browser sign-in..." />
679
+ {state.url ? (
680
+ <Box flexDirection="column" marginTop={1}>
681
+ <Text color={theme.dim}>If the browser did not open, visit:</Text>
682
+ <Text color={theme.dim}>{state.url}</Text>
683
+ </Box>
684
+ ) : null}
685
+ <Box marginTop={1}>
686
+ <Select<'cancel'>
687
+ options={[{ value: 'cancel', label: 'Cancel Sign-in' }]}
688
+ onSubmit={() => {
689
+ oauthServiceRef.current?.cleanup()
690
+ oauthServiceRef.current = null
691
+ setState({ kind: 'list', data: state.data })
692
+ }}
693
+ onCancel={() => {
694
+ oauthServiceRef.current?.cleanup()
695
+ oauthServiceRef.current = null
696
+ setState({ kind: 'list', data: state.data })
697
+ }}
698
+ />
699
+ </Box>
700
+ </Surface>
701
+ )
702
+ }
703
+
582
704
  if (state.kind === 'catalog') {
583
705
  const catalog = state.data.cloudCatalogs[state.provider]
584
706
  const options = buildCatalogOptions(state.provider, catalog, currentProvider, currentModel, contextFit)
@@ -648,7 +770,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
648
770
  options={options}
649
771
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
650
772
  maxVisible={12}
651
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel)}
773
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
652
774
  onCancel={() => setState({ kind: 'list', data: state.data })}
653
775
  />
654
776
  </Surface>
@@ -669,7 +791,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
669
791
  options={options}
670
792
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
671
793
  maxVisible={10}
672
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel)}
794
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
673
795
  onCancel={onCancel}
674
796
  />
675
797
  </Surface>
@@ -682,6 +804,8 @@ function handleSubmit(
682
804
  setState: (s: State) => void,
683
805
  onPick: (sel: ModelPickerSelection) => void,
684
806
  onCancel: () => void,
807
+ currentConfig: EthagentConfig,
808
+ oauthServiceRef: React.MutableRefObject<OpenAIOAuthService | null>,
685
809
  ): void {
686
810
  if (value.startsWith('hdr:')) return
687
811
  if (value === 'cancel') {
@@ -727,12 +851,20 @@ function handleSubmit(
727
851
  const parsed = parseKeyValue(value)
728
852
  if (!parsed) return
729
853
  if (parsed.action === 'manage') {
854
+ if (parsed.provider === 'openai' && state.data.cloudCredentialKinds?.openai === 'oauth') {
855
+ setState({ kind: 'oauthManage', data: state.data, submitting: false })
856
+ return
857
+ }
730
858
  setState({ kind: 'keyManage', provider: parsed.provider, data: state.data, submitting: false })
731
859
  return
732
860
  }
733
861
  setState({ kind: 'keyEntry', provider: parsed.provider, action: parsed.action, data: state.data, submitting: false })
734
862
  return
735
863
  }
864
+ if (value === 'oauth:openai') {
865
+ void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
866
+ return
867
+ }
736
868
  if (value.startsWith('catalog:')) {
737
869
  const provider = value.slice('catalog:'.length)
738
870
  if (isCloudProvider(provider)) setState({ kind: 'catalog', provider, data: state.data })
@@ -908,36 +1040,144 @@ async function submitKey(
908
1040
  }
909
1041
  }
910
1042
 
1043
+ async function startOpenAIOAuthFlow(
1044
+ data: LoadedData,
1045
+ currentConfig: EthagentConfig,
1046
+ setState: (s: State) => void,
1047
+ serviceRef: React.MutableRefObject<OpenAIOAuthService | null>,
1048
+ onPick: (sel: ModelPickerSelection) => void,
1049
+ ): Promise<void> {
1050
+ serviceRef.current?.cleanup()
1051
+ const service = new OpenAIOAuthService()
1052
+ serviceRef.current = service
1053
+ setState({ kind: 'oauthLogin', data, phase: 'waiting' })
1054
+ try {
1055
+ const result = await service.start(authUrl => {
1056
+ openExternalUrl(authUrl)
1057
+ setState({ kind: 'oauthLogin', data, phase: 'waiting', url: authUrl })
1058
+ })
1059
+ if (serviceRef.current !== service) return
1060
+ setState({ kind: 'oauthLogin', data, phase: 'exchanging' })
1061
+ if (result.kind === 'apikey') {
1062
+ if (typeof result.apiKey !== 'string' || result.apiKey.length === 0) {
1063
+ throw new Error(`OAuth result was apikey kind but apiKey is ${typeof result.apiKey}; refusing to store.`)
1064
+ }
1065
+ try {
1066
+ await setKey('openai', result.apiKey)
1067
+ } catch (err) {
1068
+ throw new Error(`Storing the OpenAI API key failed: ${err instanceof Error ? err.message : String(err)}`)
1069
+ }
1070
+ }
1071
+ let refreshed: LoadedData
1072
+ try {
1073
+ refreshed = await refreshProviderKeyState(data, currentConfig, 'openai')
1074
+ } catch (err) {
1075
+ throw new Error(`Refreshing the OpenAI provider state failed: ${err instanceof Error ? err.message : String(err)}`)
1076
+ }
1077
+ if (serviceRef.current !== service) return
1078
+ serviceRef.current = null
1079
+ if (result.kind === 'oauth-only' && !isOpenAIOAuthAllowedModel(currentConfig.model)) {
1080
+ onPick({ kind: 'cloud', provider: 'openai', model: OPENAI_OAUTH_DEFAULT_MODEL, keyJustSet: true })
1081
+ return
1082
+ }
1083
+ setState({ kind: 'list', data: refreshed })
1084
+ } catch (err: unknown) {
1085
+ if (serviceRef.current !== service) return
1086
+ serviceRef.current = null
1087
+ const message = err instanceof Error ? err.message : String(err)
1088
+ if (message === 'OpenAI sign-in was cancelled.') {
1089
+ setState({ kind: 'list', data })
1090
+ return
1091
+ }
1092
+ setState({ kind: 'oauthLogin', data, phase: 'error', message })
1093
+ }
1094
+ }
1095
+
911
1096
  async function deleteKey(
912
1097
  state: Extract<State, { kind: 'keyManage' }>,
913
1098
  currentConfig: EthagentConfig,
914
1099
  setState: (s: State) => void,
1100
+ onPick: (sel: ModelPickerSelection) => void,
1101
+ currentProvider: ProviderId,
915
1102
  ): Promise<void> {
916
1103
  setState({ ...state, submitting: true, error: undefined })
917
1104
  try {
918
1105
  await rmKey(state.provider)
1106
+ if (state.provider === 'openai') await rmOpenAIOAuthCredentials()
919
1107
  const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
1108
+ if (currentProvider === state.provider) {
1109
+ const fallback = pickFallbackSelection(data, state.provider)
1110
+ if (fallback) {
1111
+ onPick(fallback)
1112
+ return
1113
+ }
1114
+ }
1115
+ setState({ kind: 'list', data })
1116
+ } catch (err: unknown) {
1117
+ setState({ ...state, submitting: false, error: (err as Error).message })
1118
+ }
1119
+ }
1120
+
1121
+ async function signOutOAuth(
1122
+ state: Extract<State, { kind: 'oauthManage' }>,
1123
+ currentConfig: EthagentConfig,
1124
+ setState: (s: State) => void,
1125
+ onPick: (sel: ModelPickerSelection) => void,
1126
+ currentProvider: ProviderId,
1127
+ ): Promise<void> {
1128
+ setState({ ...state, submitting: true, error: undefined })
1129
+ try {
1130
+ await rmKey('openai')
1131
+ await rmOpenAIOAuthCredentials()
1132
+ const data = await refreshProviderKeyState(state.data, currentConfig, 'openai')
1133
+ if (currentProvider === 'openai') {
1134
+ const fallback = pickFallbackSelection(data, 'openai')
1135
+ if (fallback) {
1136
+ onPick(fallback)
1137
+ return
1138
+ }
1139
+ }
920
1140
  setState({ kind: 'list', data })
921
1141
  } catch (err: unknown) {
922
1142
  setState({ ...state, submitting: false, error: (err as Error).message })
923
1143
  }
924
1144
  }
925
1145
 
1146
+ function pickFallbackSelection(data: LoadedData, removed: ProviderId): ModelPickerSelection | null {
1147
+ for (const provider of MODEL_PICKER_CLOUD_PROVIDERS) {
1148
+ if (provider === removed) continue
1149
+ if (data.cloudKeys[provider] !== true) continue
1150
+ const catalogModel = data.cloudCatalogs[provider]?.entries[0]?.id
1151
+ const model = catalogModel ?? defaultModelFor(provider)
1152
+ return { kind: 'cloud', provider, model, keyJustSet: false }
1153
+ }
1154
+ if (data.hfModels.length > 0) {
1155
+ return { kind: 'llamacpp', model: data.hfModels[0]!.id }
1156
+ }
1157
+ return null
1158
+ }
1159
+
926
1160
  async function refreshProviderKeyState(
927
1161
  data: LoadedData,
928
1162
  currentConfig: EthagentConfig,
929
1163
  provider: CloudProviderId,
930
1164
  ): Promise<LoadedData> {
931
1165
  clearModelCatalogCache()
932
- const keySet = await hasKey(provider)
1166
+ const apiKeySet = await hasKey(provider)
1167
+ const oauthSet = provider === 'openai' ? await hasOpenAIOAuthCredentials() : false
1168
+ const keySet = apiKeySet || oauthSet
933
1169
  const cloudKeys = { ...data.cloudKeys, [provider]: keySet }
1170
+ const cloudCredentialKinds: Partial<Record<ProviderId, CloudCredentialKind>> = { ...(data.cloudCredentialKinds ?? {}) }
1171
+ if (oauthSet) cloudCredentialKinds[provider] = 'oauth'
1172
+ else if (apiKeySet) cloudCredentialKinds[provider] = 'apikey'
1173
+ else delete cloudCredentialKinds[provider]
934
1174
  const cloudCatalogs = { ...data.cloudCatalogs }
935
1175
  if (keySet) {
936
1176
  cloudCatalogs[provider] = await discoverProviderModels(configForProvider(currentConfig, provider))
937
1177
  } else {
938
1178
  delete cloudCatalogs[provider]
939
1179
  }
940
- return { ...data, cloudKeys, cloudCatalogs }
1180
+ return { ...data, cloudKeys, cloudCatalogs, cloudCredentialKinds }
941
1181
  }
942
1182
 
943
1183
  function configForProvider(config: EthagentConfig, provider: CloudProviderId): EthagentConfig {
@@ -1,6 +1,16 @@
1
1
  import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storage/config.js'
2
2
  import { getKey } from '../storage/secrets.js'
3
3
  import { loadLocalHfModels } from './huggingface.js'
4
+ import { hasOpenAIOAuthCredentials } from '../auth/openaiOAuth/credentials.js'
5
+ import { providerDisplayName } from './providerDisplay.js'
6
+
7
+ const OPENAI_OAUTH_MODEL_IDS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'] as const
8
+
9
+ export const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.4'
10
+
11
+ export function isOpenAIOAuthAllowedModel(model: string): boolean {
12
+ return (OPENAI_OAUTH_MODEL_IDS as readonly string[]).includes(model)
13
+ }
4
14
 
5
15
  export type ModelCatalogSource = 'installed' | 'discovered' | 'fallback'
6
16
 
@@ -72,7 +82,12 @@ export async function discoverProviderModels(
72
82
 
73
83
  const loadKey = deps.loadKey ?? getKey
74
84
  const apiKey = await loadKey(provider)
75
- if (!apiKey) return fallbackResult(config, `missing ${provider} API key`)
85
+ if (!apiKey) {
86
+ if (provider === 'openai' && await hasOpenAIOAuthCredentials()) {
87
+ return openAIOAuthCatalog()
88
+ }
89
+ return fallbackResult(config, `missing ${providerDisplayName(provider)} API key`)
90
+ }
76
91
 
77
92
  const baseUrl = provider === 'openai' ? openAIBaseUrlFor(config) : ''
78
93
  const key = cacheKey(provider, baseUrl, true)
@@ -98,6 +113,19 @@ export async function discoverProviderModels(
98
113
  }
99
114
  }
100
115
 
116
+ function openAIOAuthCatalog(): ModelCatalogResult {
117
+ return {
118
+ provider: 'openai',
119
+ status: 'ok',
120
+ entries: OPENAI_OAUTH_MODEL_IDS.map(id => ({
121
+ provider: 'openai' as ProviderId,
122
+ id,
123
+ label: id,
124
+ source: 'discovered' as const,
125
+ })),
126
+ }
127
+ }
128
+
101
129
  function fallbackResult(config: EthagentConfig, error?: string): ModelCatalogResult {
102
130
  const provider = config.provider
103
131
  return {
@@ -7,21 +7,14 @@ import { type SelectOption } from '../ui/Select.js'
7
7
  import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDisplay.js'
8
8
  import { localModelId, quantizationFromFilename } from './huggingface.js'
9
9
  import type { UncensoredCatalogEntry } from './uncensoredCatalog.js'
10
+ import { cloudProviderDisplayName, providerDisplayName, type CloudProviderId } from './providerDisplay.js'
10
11
 
11
- export type CloudProviderId = Exclude<ProviderId, 'llamacpp'>
12
+ export { cloudProviderDisplayName, providerDisplayName, type CloudProviderId }
12
13
 
13
14
  export const MODEL_PICKER_CLOUD_PROVIDERS: CloudProviderId[] = ['openai', 'anthropic', 'gemini']
14
15
  export const LOCAL_MODEL_LINK_HINT = 'Paste a GGUF link'
15
16
  export const LOCAL_MODEL_LINK_EXAMPLE = 'e.g. https://huggingface.co/Qwen/Qwen3-8B-GGUF'
16
17
 
17
- export function cloudProviderDisplayName(provider: CloudProviderId): string {
18
- switch (provider) {
19
- case 'openai': return 'OpenAI'
20
- case 'anthropic': return 'Anthropic'
21
- case 'gemini': return 'Gemini'
22
- }
23
- }
24
-
25
18
  export type LocalHfPickerModel = {
26
19
  id: string
27
20
  displayName: string
@@ -32,6 +25,8 @@ export type LocalHfPickerModel = {
32
25
  status: 'ready' | 'incomplete'
33
26
  }
34
27
 
28
+ export type CloudCredentialKind = 'apikey' | 'oauth'
29
+
35
30
  export type ModelPickerOptionsData = {
36
31
  llamaCpp: {
37
32
  binaryPresent: boolean
@@ -42,6 +37,7 @@ export type ModelPickerOptionsData = {
42
37
  machineSpec?: SpecSnapshot
43
38
  cloudKeys: Partial<Record<ProviderId, boolean>>
44
39
  cloudCatalogs: Partial<Record<ProviderId, ModelCatalogResult>>
40
+ cloudCredentialKinds?: Partial<Record<ProviderId, CloudCredentialKind>>
45
41
  }
46
42
 
47
43
  export type ModelPickerContextFit = {
@@ -78,6 +74,9 @@ export function buildModelPickerOptions(
78
74
  options.push(groupOption(`hdr:cloud:${provider}`, cloudProviderDisplayName(provider)))
79
75
  const keySet = data.cloudKeys[provider] === true
80
76
  if (!keySet) {
77
+ if (provider === 'openai') {
78
+ options.push(utilityOption('oauth:openai', 'Sign in with ChatGPT', 'Use your ChatGPT subscription'))
79
+ }
81
80
  options.push(utilityOption(`key:set:${provider}`, 'Add API Key'))
82
81
  continue
83
82
  }
@@ -105,7 +104,10 @@ export function buildModelPickerOptions(
105
104
  ))
106
105
  }
107
106
  options.push(utilityOption(`catalog:${provider}`, 'Full Catalog'))
108
- options.push(utilityOption(`key:manage:${provider}`, 'Manage API Key'))
107
+ const manageLabel = provider === 'openai' && data.cloudCredentialKinds?.openai === 'oauth'
108
+ ? 'Manage ChatGPT Sign-in'
109
+ : 'Manage API Key'
110
+ options.push(utilityOption(`key:manage:${provider}`, manageLabel))
109
111
  }
110
112
 
111
113
  options.push(sectionOption('hdr:exit', 'Exit'))
@@ -0,0 +1,16 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+
3
+ export type CloudProviderId = Exclude<ProviderId, 'llamacpp'>
4
+
5
+ export function cloudProviderDisplayName(provider: CloudProviderId): string {
6
+ switch (provider) {
7
+ case 'openai': return 'OpenAI'
8
+ case 'anthropic': return 'Anthropic'
9
+ case 'gemini': return 'Gemini'
10
+ }
11
+ }
12
+
13
+ export function providerDisplayName(provider: ProviderId): string {
14
+ if (provider === 'llamacpp') return 'llama.cpp'
15
+ return cloudProviderDisplayName(provider)
16
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ProviderId } from '../storage/config.js'
2
2
  import { ProviderError } from './contracts.js'
3
3
  import { formatGeminiRateLimitMessage } from './gemini.js'
4
+ import { providerDisplayName } from '../models/providerDisplay.js'
4
5
 
5
6
  type ErrorBody =
6
7
  | string
@@ -30,19 +31,20 @@ export async function providerErrorFromResponse(
30
31
  && /API[_ ]?key( not valid| not found|_invalid)|invalid api key/i.test(detail)
31
32
  ) {
32
33
  return new ProviderError(
33
- 'gemini: API key rejected — verify your key at https://aistudio.google.com/app/apikey, then run /key gemini to set it again',
34
+ 'Gemini: API key rejected — verify your key at https://aistudio.google.com/app/apikey, then run /key gemini to set it again',
34
35
  )
35
36
  }
36
37
 
37
38
  if (provider !== 'llamacpp') {
39
+ const name = providerDisplayName(provider)
38
40
  if (response.status === 401 || response.status === 403) {
39
- return new ProviderError(`auth failed: check your ${provider} key (/doctor to verify)`)
41
+ return new ProviderError(`auth failed: check your ${name} key (/doctor to verify)`)
40
42
  }
41
43
  if (response.status === 429) {
42
- return new ProviderError(detail || `${provider} rate limit exceeded`, { transient: true })
44
+ return new ProviderError(detail || `${name} rate limit exceeded`, { transient: true })
43
45
  }
44
46
  if (response.status >= 500) {
45
- return new ProviderError(detail || `${provider} server error (${response.status})`, { transient: true })
47
+ return new ProviderError(detail || `${name} server error (${response.status})`, { transient: true })
46
48
  }
47
49
  }
48
50
 
@@ -5,6 +5,7 @@ import { providerErrorFromResponse } from './errors.js'
5
5
  import { fetchWithRetryStreamEvents } from './retry.js'
6
6
  import { iterSseFrames } from './sse.js'
7
7
  import { messageTextContent } from '../utils/messages.js'
8
+ import { providerDisplayName } from '../models/providerDisplay.js'
8
9
 
9
10
  export type OpenAIToolDefinition = {
10
11
  type: 'function'
@@ -369,7 +370,7 @@ function providerNetworkErrorMessage(
369
370
  ): string {
370
371
  const message = (err as Error).message || fallback
371
372
  if (provider !== 'llamacpp') return message
372
- return `${provider} request failed at ${baseUrl}: ${message}`
373
+ return `${providerDisplayName(provider)} request failed at ${baseUrl}: ${message}`
373
374
  }
374
375
 
375
376
  class ContentThinkingParser {