ethagent 2.1.1 → 2.2.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/auth/openaiOAuth/credentials.ts +47 -0
  3. package/src/auth/openaiOAuth/crypto.ts +23 -0
  4. package/src/auth/openaiOAuth/index.ts +238 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  6. package/src/auth/openaiOAuth/listener.ts +151 -0
  7. package/src/auth/openaiOAuth/refresh.ts +70 -0
  8. package/src/auth/openaiOAuth/shared.ts +115 -0
  9. package/src/chat/chatSessionState.ts +2 -1
  10. package/src/chat/commands.ts +2 -1
  11. package/src/identity/ens/agentRecords.ts +5 -19
  12. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  13. package/src/identity/ens/ensAutomation/types.ts +0 -1
  14. package/src/identity/hub/OperationalRoutes.tsx +2 -11
  15. package/src/identity/hub/components/IdentitySummary.tsx +8 -3
  16. package/src/identity/hub/components/MenuScreen.tsx +1 -2
  17. package/src/identity/hub/components/menuFlagsFromReconciliation.ts +1 -3
  18. package/src/identity/hub/effects/ens/transactions.ts +15 -15
  19. package/src/identity/hub/effects/index.ts +0 -1
  20. package/src/identity/hub/effects/profile/profileState.ts +12 -4
  21. package/src/identity/hub/effects/publicProfile/runPublicProfileSave.ts +37 -159
  22. package/src/identity/hub/effects/rebackup/runRebackup.ts +2 -2
  23. package/src/identity/hub/effects/restoreAdmin.ts +2 -61
  24. package/src/identity/hub/effects/shared/sync.ts +3 -44
  25. package/src/identity/hub/flows/custody/CustodyEditFlow.tsx +1 -39
  26. package/src/identity/hub/flows/custody/custodyFlowActions.ts +5 -3
  27. package/src/identity/hub/flows/custody/custodyFlowTypes.ts +1 -1
  28. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +80 -175
  29. package/src/identity/hub/flows/ens/EnsEditFlow.tsx +20 -75
  30. package/src/identity/hub/flows/ens/EnsEditMaintenanceScreens.tsx +16 -56
  31. package/src/identity/hub/flows/ens/EnsEditReviewScreens.tsx +0 -18
  32. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -136
  33. package/src/identity/hub/flows/ens/EnsEditShared.tsx +5 -4
  34. package/src/identity/hub/flows/ens/EnsEditSimpleScreens.tsx +56 -205
  35. package/src/identity/hub/flows/ens/IdentityHubEnsFlow.tsx +7 -0
  36. package/src/identity/hub/flows/ens/OperatorWalletsScreen.tsx +0 -31
  37. package/src/identity/hub/flows/ens/ensEditCopy.ts +1 -1
  38. package/src/identity/hub/flows/ens/ensEditTypes.ts +6 -20
  39. package/src/identity/hub/flows/profile/EditProfileFlow.tsx +7 -0
  40. package/src/identity/hub/flows/restore/RestoreFlow.tsx +5 -5
  41. package/src/identity/hub/reconciliation/agentReconciliation/hook.ts +0 -1
  42. package/src/identity/hub/reconciliation/agentReconciliation/run.ts +1 -34
  43. package/src/identity/hub/reconciliation/agentReconciliation/types.ts +0 -4
  44. package/src/identity/hub/reconciliation/index.ts +0 -7
  45. package/src/identity/hub/reconciliation/walletSetup.ts +1 -194
  46. package/src/identity/wallet/browserWallet/types.ts +0 -5
  47. package/src/identity/wallet/page/copy.ts +1 -31
  48. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  49. package/src/models/ModelPicker.tsx +246 -8
  50. package/src/models/catalog.ts +28 -1
  51. package/src/models/modelPickerOptions.ts +15 -1
  52. package/src/providers/openai-responses-format.ts +156 -0
  53. package/src/providers/openai-responses.ts +276 -0
  54. package/src/providers/registry.ts +85 -8
  55. package/src/runtime/systemPrompt.ts +1 -1
  56. package/src/runtime/turn.ts +0 -1
  57. package/src/storage/secrets.ts +4 -1
  58. package/src/tools/privateContinuityEditTool.ts +6 -0
  59. package/src/utils/openExternal.ts +20 -10
  60. package/src/identity/ens/ensRegistration.ts +0 -199
@@ -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,7 @@ 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)
573
593
  }}
574
594
  onCancel={() => setState({ kind: 'list', data: state.data })}
575
595
  />
@@ -579,6 +599,106 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
579
599
  )
580
600
  }
581
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: 'signin', label: 'Sign in Again' },
616
+ { value: 'signout', label: 'Sign Out' },
617
+ { value: 'cancel', label: 'Back' },
618
+ ]}
619
+ onSubmit={(value) => {
620
+ if (value === 'signin') {
621
+ void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
622
+ return
623
+ }
624
+ if (value === 'cancel') {
625
+ setState({ kind: 'list', data: state.data })
626
+ return
627
+ }
628
+ void signOutOAuth(state, currentConfig, setState, onPick, currentProvider)
629
+ }}
630
+ onCancel={() => setState({ kind: 'list', data: state.data })}
631
+ />
632
+ )}
633
+ {error ? <Text color={theme.accentError}>{error}</Text> : null}
634
+ </Surface>
635
+ )
636
+ }
637
+
638
+ if (state.kind === 'oauthLogin') {
639
+ if (state.phase === 'error') {
640
+ return (
641
+ <Surface
642
+ title="OpenAI Sign-in Failed"
643
+ subtitle={state.message ?? 'Sign-in did not complete.'}
644
+ tone="error"
645
+ footer="enter select · esc back"
646
+ >
647
+ <Select<'retry' | 'apikey' | 'back'>
648
+ options={[
649
+ { value: 'retry', label: 'Try Again' },
650
+ { value: 'apikey', label: 'Add API Key Instead' },
651
+ { value: 'back', label: 'Back To Picker' },
652
+ ]}
653
+ onSubmit={choice => {
654
+ if (choice === 'retry') void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
655
+ else if (choice === 'apikey') setState({ kind: 'keyEntry', provider: 'openai', action: 'set', data: state.data, submitting: false })
656
+ else setState({ kind: 'list', data: state.data })
657
+ }}
658
+ onCancel={() => setState({ kind: 'list', data: state.data })}
659
+ />
660
+ </Surface>
661
+ )
662
+ }
663
+ if (state.phase === 'exchanging') {
664
+ return (
665
+ <Surface title="Finishing OpenAI Sign-in" subtitle="Exchanging credentials with auth.openai.com.">
666
+ <Spinner label="completing sign-in..." />
667
+ </Surface>
668
+ )
669
+ }
670
+ return (
671
+ <Surface
672
+ title="Sign in with ChatGPT"
673
+ subtitle="Opened your browser to auth.openai.com. Approve to continue."
674
+ footer="esc cancel"
675
+ >
676
+ <Spinner label="waiting for browser sign-in..." />
677
+ {state.url ? (
678
+ <Box flexDirection="column" marginTop={1}>
679
+ <Text color={theme.dim}>If the browser did not open, visit:</Text>
680
+ <Text color={theme.dim}>{state.url}</Text>
681
+ </Box>
682
+ ) : null}
683
+ <Box marginTop={1}>
684
+ <Select<'cancel'>
685
+ options={[{ value: 'cancel', label: 'Cancel Sign-in' }]}
686
+ onSubmit={() => {
687
+ oauthServiceRef.current?.cleanup()
688
+ oauthServiceRef.current = null
689
+ setState({ kind: 'list', data: state.data })
690
+ }}
691
+ onCancel={() => {
692
+ oauthServiceRef.current?.cleanup()
693
+ oauthServiceRef.current = null
694
+ setState({ kind: 'list', data: state.data })
695
+ }}
696
+ />
697
+ </Box>
698
+ </Surface>
699
+ )
700
+ }
701
+
582
702
  if (state.kind === 'catalog') {
583
703
  const catalog = state.data.cloudCatalogs[state.provider]
584
704
  const options = buildCatalogOptions(state.provider, catalog, currentProvider, currentModel, contextFit)
@@ -648,7 +768,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
648
768
  options={options}
649
769
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
650
770
  maxVisible={12}
651
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel)}
771
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
652
772
  onCancel={() => setState({ kind: 'list', data: state.data })}
653
773
  />
654
774
  </Surface>
@@ -669,7 +789,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
669
789
  options={options}
670
790
  initialIndex={initialIndex === -1 ? 0 : initialIndex}
671
791
  maxVisible={10}
672
- onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel)}
792
+ onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef)}
673
793
  onCancel={onCancel}
674
794
  />
675
795
  </Surface>
@@ -682,6 +802,8 @@ function handleSubmit(
682
802
  setState: (s: State) => void,
683
803
  onPick: (sel: ModelPickerSelection) => void,
684
804
  onCancel: () => void,
805
+ currentConfig: EthagentConfig,
806
+ oauthServiceRef: React.MutableRefObject<OpenAIOAuthService | null>,
685
807
  ): void {
686
808
  if (value.startsWith('hdr:')) return
687
809
  if (value === 'cancel') {
@@ -727,12 +849,20 @@ function handleSubmit(
727
849
  const parsed = parseKeyValue(value)
728
850
  if (!parsed) return
729
851
  if (parsed.action === 'manage') {
852
+ if (parsed.provider === 'openai' && state.data.cloudCredentialKinds?.openai === 'oauth') {
853
+ setState({ kind: 'oauthManage', data: state.data, submitting: false })
854
+ return
855
+ }
730
856
  setState({ kind: 'keyManage', provider: parsed.provider, data: state.data, submitting: false })
731
857
  return
732
858
  }
733
859
  setState({ kind: 'keyEntry', provider: parsed.provider, action: parsed.action, data: state.data, submitting: false })
734
860
  return
735
861
  }
862
+ if (value === 'oauth:openai') {
863
+ void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
864
+ return
865
+ }
736
866
  if (value.startsWith('catalog:')) {
737
867
  const provider = value.slice('catalog:'.length)
738
868
  if (isCloudProvider(provider)) setState({ kind: 'catalog', provider, data: state.data })
@@ -908,36 +1038,144 @@ async function submitKey(
908
1038
  }
909
1039
  }
910
1040
 
1041
+ async function startOpenAIOAuthFlow(
1042
+ data: LoadedData,
1043
+ currentConfig: EthagentConfig,
1044
+ setState: (s: State) => void,
1045
+ serviceRef: React.MutableRefObject<OpenAIOAuthService | null>,
1046
+ onPick: (sel: ModelPickerSelection) => void,
1047
+ ): Promise<void> {
1048
+ serviceRef.current?.cleanup()
1049
+ const service = new OpenAIOAuthService()
1050
+ serviceRef.current = service
1051
+ setState({ kind: 'oauthLogin', data, phase: 'waiting' })
1052
+ try {
1053
+ const result = await service.start(authUrl => {
1054
+ openExternalUrl(authUrl)
1055
+ setState({ kind: 'oauthLogin', data, phase: 'waiting', url: authUrl })
1056
+ })
1057
+ if (serviceRef.current !== service) return
1058
+ setState({ kind: 'oauthLogin', data, phase: 'exchanging' })
1059
+ if (result.kind === 'apikey') {
1060
+ if (typeof result.apiKey !== 'string' || result.apiKey.length === 0) {
1061
+ throw new Error(`OAuth result was apikey kind but apiKey is ${typeof result.apiKey}; refusing to store.`)
1062
+ }
1063
+ try {
1064
+ await setKey('openai', result.apiKey)
1065
+ } catch (err) {
1066
+ throw new Error(`Storing the OpenAI API key failed: ${err instanceof Error ? err.message : String(err)}`)
1067
+ }
1068
+ }
1069
+ let refreshed: LoadedData
1070
+ try {
1071
+ refreshed = await refreshProviderKeyState(data, currentConfig, 'openai')
1072
+ } catch (err) {
1073
+ throw new Error(`Refreshing the OpenAI provider state failed: ${err instanceof Error ? err.message : String(err)}`)
1074
+ }
1075
+ if (serviceRef.current !== service) return
1076
+ serviceRef.current = null
1077
+ if (result.kind === 'oauth-only' && !isOpenAIOAuthAllowedModel(currentConfig.model)) {
1078
+ onPick({ kind: 'cloud', provider: 'openai', model: OPENAI_OAUTH_DEFAULT_MODEL, keyJustSet: true })
1079
+ return
1080
+ }
1081
+ setState({ kind: 'list', data: refreshed })
1082
+ } catch (err: unknown) {
1083
+ if (serviceRef.current !== service) return
1084
+ serviceRef.current = null
1085
+ const message = err instanceof Error ? err.message : String(err)
1086
+ if (message === 'OpenAI sign-in was cancelled.') {
1087
+ setState({ kind: 'list', data })
1088
+ return
1089
+ }
1090
+ setState({ kind: 'oauthLogin', data, phase: 'error', message })
1091
+ }
1092
+ }
1093
+
911
1094
  async function deleteKey(
912
1095
  state: Extract<State, { kind: 'keyManage' }>,
913
1096
  currentConfig: EthagentConfig,
914
1097
  setState: (s: State) => void,
1098
+ onPick: (sel: ModelPickerSelection) => void,
1099
+ currentProvider: ProviderId,
915
1100
  ): Promise<void> {
916
1101
  setState({ ...state, submitting: true, error: undefined })
917
1102
  try {
918
1103
  await rmKey(state.provider)
1104
+ if (state.provider === 'openai') await rmOpenAIOAuthCredentials()
919
1105
  const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
1106
+ if (currentProvider === state.provider) {
1107
+ const fallback = pickFallbackSelection(data, state.provider)
1108
+ if (fallback) {
1109
+ onPick(fallback)
1110
+ return
1111
+ }
1112
+ }
1113
+ setState({ kind: 'list', data })
1114
+ } catch (err: unknown) {
1115
+ setState({ ...state, submitting: false, error: (err as Error).message })
1116
+ }
1117
+ }
1118
+
1119
+ async function signOutOAuth(
1120
+ state: Extract<State, { kind: 'oauthManage' }>,
1121
+ currentConfig: EthagentConfig,
1122
+ setState: (s: State) => void,
1123
+ onPick: (sel: ModelPickerSelection) => void,
1124
+ currentProvider: ProviderId,
1125
+ ): Promise<void> {
1126
+ setState({ ...state, submitting: true, error: undefined })
1127
+ try {
1128
+ await rmKey('openai')
1129
+ await rmOpenAIOAuthCredentials()
1130
+ const data = await refreshProviderKeyState(state.data, currentConfig, 'openai')
1131
+ if (currentProvider === 'openai') {
1132
+ const fallback = pickFallbackSelection(data, 'openai')
1133
+ if (fallback) {
1134
+ onPick(fallback)
1135
+ return
1136
+ }
1137
+ }
920
1138
  setState({ kind: 'list', data })
921
1139
  } catch (err: unknown) {
922
1140
  setState({ ...state, submitting: false, error: (err as Error).message })
923
1141
  }
924
1142
  }
925
1143
 
1144
+ function pickFallbackSelection(data: LoadedData, removed: ProviderId): ModelPickerSelection | null {
1145
+ for (const provider of MODEL_PICKER_CLOUD_PROVIDERS) {
1146
+ if (provider === removed) continue
1147
+ if (data.cloudKeys[provider] !== true) continue
1148
+ const catalogModel = data.cloudCatalogs[provider]?.entries[0]?.id
1149
+ const model = catalogModel ?? defaultModelFor(provider)
1150
+ return { kind: 'cloud', provider, model, keyJustSet: false }
1151
+ }
1152
+ if (data.hfModels.length > 0) {
1153
+ return { kind: 'llamacpp', model: data.hfModels[0]!.id }
1154
+ }
1155
+ return null
1156
+ }
1157
+
926
1158
  async function refreshProviderKeyState(
927
1159
  data: LoadedData,
928
1160
  currentConfig: EthagentConfig,
929
1161
  provider: CloudProviderId,
930
1162
  ): Promise<LoadedData> {
931
1163
  clearModelCatalogCache()
932
- const keySet = await hasKey(provider)
1164
+ const apiKeySet = await hasKey(provider)
1165
+ const oauthSet = provider === 'openai' ? await hasOpenAIOAuthCredentials() : false
1166
+ const keySet = apiKeySet || oauthSet
933
1167
  const cloudKeys = { ...data.cloudKeys, [provider]: keySet }
1168
+ const cloudCredentialKinds: Partial<Record<ProviderId, CloudCredentialKind>> = { ...(data.cloudCredentialKinds ?? {}) }
1169
+ if (oauthSet) cloudCredentialKinds[provider] = 'oauth'
1170
+ else if (apiKeySet) cloudCredentialKinds[provider] = 'apikey'
1171
+ else delete cloudCredentialKinds[provider]
934
1172
  const cloudCatalogs = { ...data.cloudCatalogs }
935
1173
  if (keySet) {
936
1174
  cloudCatalogs[provider] = await discoverProviderModels(configForProvider(currentConfig, provider))
937
1175
  } else {
938
1176
  delete cloudCatalogs[provider]
939
1177
  }
940
- return { ...data, cloudKeys, cloudCatalogs }
1178
+ return { ...data, cloudKeys, cloudCatalogs, cloudCredentialKinds }
941
1179
  }
942
1180
 
943
1181
  function configForProvider(config: EthagentConfig, provider: CloudProviderId): EthagentConfig {
@@ -1,6 +1,15 @@
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
+
6
+ const OPENAI_OAUTH_MODEL_IDS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'] as const
7
+
8
+ export const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.4'
9
+
10
+ export function isOpenAIOAuthAllowedModel(model: string): boolean {
11
+ return (OPENAI_OAUTH_MODEL_IDS as readonly string[]).includes(model)
12
+ }
4
13
 
5
14
  export type ModelCatalogSource = 'installed' | 'discovered' | 'fallback'
6
15
 
@@ -72,7 +81,12 @@ export async function discoverProviderModels(
72
81
 
73
82
  const loadKey = deps.loadKey ?? getKey
74
83
  const apiKey = await loadKey(provider)
75
- if (!apiKey) return fallbackResult(config, `missing ${provider} API key`)
84
+ if (!apiKey) {
85
+ if (provider === 'openai' && await hasOpenAIOAuthCredentials()) {
86
+ return openAIOAuthCatalog()
87
+ }
88
+ return fallbackResult(config, `missing ${provider} API key`)
89
+ }
76
90
 
77
91
  const baseUrl = provider === 'openai' ? openAIBaseUrlFor(config) : ''
78
92
  const key = cacheKey(provider, baseUrl, true)
@@ -98,6 +112,19 @@ export async function discoverProviderModels(
98
112
  }
99
113
  }
100
114
 
115
+ function openAIOAuthCatalog(): ModelCatalogResult {
116
+ return {
117
+ provider: 'openai',
118
+ status: 'ok',
119
+ entries: OPENAI_OAUTH_MODEL_IDS.map(id => ({
120
+ provider: 'openai' as ProviderId,
121
+ id,
122
+ label: id,
123
+ source: 'discovered' as const,
124
+ })),
125
+ }
126
+ }
127
+
101
128
  function fallbackResult(config: EthagentConfig, error?: string): ModelCatalogResult {
102
129
  const provider = config.provider
103
130
  return {
@@ -22,6 +22,11 @@ export function cloudProviderDisplayName(provider: CloudProviderId): string {
22
22
  }
23
23
  }
24
24
 
25
+ export function providerDisplayName(provider: ProviderId): string {
26
+ if (provider === 'llamacpp') return 'llama.cpp'
27
+ return cloudProviderDisplayName(provider)
28
+ }
29
+
25
30
  export type LocalHfPickerModel = {
26
31
  id: string
27
32
  displayName: string
@@ -32,6 +37,8 @@ export type LocalHfPickerModel = {
32
37
  status: 'ready' | 'incomplete'
33
38
  }
34
39
 
40
+ export type CloudCredentialKind = 'apikey' | 'oauth'
41
+
35
42
  export type ModelPickerOptionsData = {
36
43
  llamaCpp: {
37
44
  binaryPresent: boolean
@@ -42,6 +49,7 @@ export type ModelPickerOptionsData = {
42
49
  machineSpec?: SpecSnapshot
43
50
  cloudKeys: Partial<Record<ProviderId, boolean>>
44
51
  cloudCatalogs: Partial<Record<ProviderId, ModelCatalogResult>>
52
+ cloudCredentialKinds?: Partial<Record<ProviderId, CloudCredentialKind>>
45
53
  }
46
54
 
47
55
  export type ModelPickerContextFit = {
@@ -78,6 +86,9 @@ export function buildModelPickerOptions(
78
86
  options.push(groupOption(`hdr:cloud:${provider}`, cloudProviderDisplayName(provider)))
79
87
  const keySet = data.cloudKeys[provider] === true
80
88
  if (!keySet) {
89
+ if (provider === 'openai') {
90
+ options.push(utilityOption('oauth:openai', 'Sign in with ChatGPT', 'Use your ChatGPT subscription'))
91
+ }
81
92
  options.push(utilityOption(`key:set:${provider}`, 'Add API Key'))
82
93
  continue
83
94
  }
@@ -105,7 +116,10 @@ export function buildModelPickerOptions(
105
116
  ))
106
117
  }
107
118
  options.push(utilityOption(`catalog:${provider}`, 'Full Catalog'))
108
- options.push(utilityOption(`key:manage:${provider}`, 'Manage API Key'))
119
+ const manageLabel = provider === 'openai' && data.cloudCredentialKinds?.openai === 'oauth'
120
+ ? 'Manage ChatGPT Sign-in'
121
+ : 'Manage API Key'
122
+ options.push(utilityOption(`key:manage:${provider}`, manageLabel))
109
123
  }
110
124
 
111
125
  options.push(sectionOption('hdr:exit', 'Exit'))
@@ -0,0 +1,156 @@
1
+ import type { Message, MessageContentBlock } from './contracts.js'
2
+ import { messageTextContent } from '../utils/messages.js'
3
+ import type { OpenAIToolDefinition } from './openai-chat.js'
4
+
5
+ export type ResponsesInputContent =
6
+ | { type: 'input_text'; text: string }
7
+ | { type: 'output_text'; text: string }
8
+
9
+ export type ResponsesInputItem =
10
+ | { type: 'message'; role: 'user' | 'assistant'; content: ResponsesInputContent[] }
11
+ | { type: 'function_call'; id?: string; call_id: string; name: string; arguments: string }
12
+ | { type: 'function_call_output'; call_id: string; output: string }
13
+
14
+ export type ResponsesTool = {
15
+ type: 'function'
16
+ name: string
17
+ description: string
18
+ parameters: Record<string, unknown>
19
+ }
20
+
21
+ export type ResponsesRequestBody = {
22
+ model: string
23
+ input: ResponsesInputItem[]
24
+ instructions?: string
25
+ tools?: ResponsesTool[]
26
+ tool_choice?: 'auto' | 'none' | 'required'
27
+ parallel_tool_calls?: boolean
28
+ stream: true
29
+ store: false
30
+ max_output_tokens?: number
31
+ }
32
+
33
+ export function buildResponsesBody(args: {
34
+ model: string
35
+ messages: Message[]
36
+ tools: OpenAIToolDefinition[]
37
+ maxOutputTokens?: number
38
+ }): ResponsesRequestBody {
39
+ const { instructions, items } = splitMessages(args.messages)
40
+ const body: ResponsesRequestBody = {
41
+ model: args.model,
42
+ input: items,
43
+ stream: true,
44
+ store: false,
45
+ }
46
+ if (instructions) body.instructions = instructions
47
+ if (args.tools.length > 0) {
48
+ body.tools = args.tools.map(tool => ({
49
+ type: 'function' as const,
50
+ name: tool.function.name,
51
+ description: tool.function.description,
52
+ parameters: tool.function.parameters as Record<string, unknown>,
53
+ }))
54
+ body.parallel_tool_calls = true
55
+ body.tool_choice = 'auto'
56
+ }
57
+ if (args.maxOutputTokens !== undefined) {
58
+ body.max_output_tokens = args.maxOutputTokens
59
+ }
60
+ return body
61
+ }
62
+
63
+ function splitMessages(messages: Message[]): {
64
+ instructions?: string
65
+ items: ResponsesInputItem[]
66
+ } {
67
+ const instructions: string[] = []
68
+ const items: ResponsesInputItem[] = []
69
+
70
+ for (const message of messages) {
71
+ if (message.role === 'system') {
72
+ const text = typeof message.content === 'string'
73
+ ? message.content
74
+ : messageTextContent(message)
75
+ if (text) instructions.push(text)
76
+ continue
77
+ }
78
+
79
+ if (message.role === 'user') {
80
+ const blocks = normalizeBlocks(message.content)
81
+ const toolResults = blocks.filter(isToolResultBlock)
82
+ if (toolResults.length > 0) {
83
+ for (const block of toolResults) {
84
+ items.push({
85
+ type: 'function_call_output',
86
+ call_id: block.toolUseId,
87
+ output: block.content,
88
+ })
89
+ }
90
+ const remainingText = blocks
91
+ .filter(isTextBlock)
92
+ .map(block => block.text)
93
+ .join('')
94
+ if (remainingText) {
95
+ items.push({
96
+ type: 'message',
97
+ role: 'user',
98
+ content: [{ type: 'input_text', text: remainingText }],
99
+ })
100
+ }
101
+ continue
102
+ }
103
+ const text = blocks.filter(isTextBlock).map(block => block.text).join('')
104
+ if (text) {
105
+ items.push({
106
+ type: 'message',
107
+ role: 'user',
108
+ content: [{ type: 'input_text', text }],
109
+ })
110
+ }
111
+ continue
112
+ }
113
+
114
+ const blocks = normalizeBlocks(message.content)
115
+ const text = blocks.filter(isTextBlock).map(block => block.text).join('')
116
+ if (text) {
117
+ items.push({
118
+ type: 'message',
119
+ role: 'assistant',
120
+ content: [{ type: 'output_text', text }],
121
+ })
122
+ }
123
+ for (const block of blocks.filter(isToolUseBlock)) {
124
+ items.push({
125
+ type: 'function_call',
126
+ call_id: block.id,
127
+ name: block.name,
128
+ arguments: JSON.stringify(block.input),
129
+ })
130
+ }
131
+ }
132
+
133
+ return {
134
+ instructions: instructions.length > 0 ? instructions.join('\n\n') : undefined,
135
+ items,
136
+ }
137
+ }
138
+
139
+ function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
140
+ if (typeof content === 'string') {
141
+ return content ? [{ type: 'text', text: content }] : []
142
+ }
143
+ return content
144
+ }
145
+
146
+ function isTextBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'text' }> {
147
+ return block.type === 'text'
148
+ }
149
+
150
+ function isToolUseBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_use' }> {
151
+ return block.type === 'tool_use'
152
+ }
153
+
154
+ function isToolResultBlock(block: MessageContentBlock): block is Extract<MessageContentBlock, { type: 'tool_result' }> {
155
+ return block.type === 'tool_result'
156
+ }