Kode-cli/src/components/ConsoleOAuthFlow.tsx
CrazyBoyM 6cf566fb40 feat: Add comprehensive GPT-5 support with Responses API integration
- Add GPT-5 model definitions (gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-chat-latest)
- Implement GPT-5 Responses API support with intelligent fallback to Chat Completions
- Add GPT-5 specific parameter handling (max_completion_tokens, temperature=1)
- Support custom tools and freeform function calling capabilities
- Add reasoning effort and verbosity control parameters
- Implement GPT-5 connection testing service
- Add model capability detection and automatic parameter transformation
- Support both official OpenAI and third-party GPT-5 providers
- Add todo list and sticker request UI components
- Improve notebook support with better type definitions
- Enhance debug logging and error handling for GPT-5
- Update model selector with GPT-5 compatibility checks

This commit provides full GPT-5 support while maintaining backward compatibility with existing models.
2025-08-13 01:38:15 +08:00

328 lines
9.9 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react'
import { Static, Box, Text, useInput } from 'ink'
import TextInput from './TextInput'
import { OAuthService, createAndStoreApiKey } from '../services/oauth'
import { getTheme } from '../utils/theme'
import { logEvent } from '../services/statsig'
import { AsciiLogo } from './AsciiLogo'
import { useTerminalSize } from '../hooks/useTerminalSize'
import { logError } from '../utils/log'
import { clearTerminal } from '../utils/terminal'
import { SimpleSpinner } from './Spinner'
import { WelcomeBox } from './Onboarding'
import { PRODUCT_NAME } from '../constants/product'
import { sendNotification } from '../services/notifier'
type Props = {
onDone(): void
}
type OAuthStatus =
| { state: 'idle' }
| { state: 'ready_to_start' }
| { state: 'waiting_for_login'; url: string }
| { state: 'creating_api_key' }
| { state: 'about_to_retry'; nextState: OAuthStatus }
| { state: 'success'; apiKey: string }
| {
state: 'error'
message: string
toRetry?: OAuthStatus
}
const PASTE_HERE_MSG = 'Paste code here if prompted > '
export function ConsoleOAuthFlow({ onDone }: Props): React.ReactNode {
const [oauthStatus, setOAuthStatus] = useState<OAuthStatus>({
state: 'idle',
})
const theme = getTheme()
const [pastedCode, setPastedCode] = useState('')
const [cursorOffset, setCursorOffset] = useState(0)
const [oauthService] = useState(() => new OAuthService())
// After a few seconds we suggest the user to copy/paste url if the
// browser did not open automatically. In this flow we expect the user to
// copy the code from the browser and paste it in the terminal
const [showPastePrompt, setShowPastePrompt] = useState(false)
// we need a special clearing state to correctly re-render Static elements
const [isClearing, setIsClearing] = useState(false)
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
useEffect(() => {
if (isClearing) {
clearTerminal()
setIsClearing(false)
}
}, [isClearing])
// Retry logic
useEffect(() => {
if (oauthStatus.state === 'about_to_retry') {
setIsClearing(true)
setTimeout(() => {
setOAuthStatus(oauthStatus.nextState)
}, 1000)
}
}, [oauthStatus])
useInput(async (_, key) => {
if (key.return) {
if (oauthStatus.state === 'idle') {
logEvent('tengu_oauth_start', {})
setOAuthStatus({ state: 'ready_to_start' })
} else if (oauthStatus.state === 'success') {
logEvent('tengu_oauth_success', {})
await clearTerminal() // needed to clear out Static components
onDone()
} else if (oauthStatus.state === 'error' && oauthStatus.toRetry) {
setPastedCode('')
setOAuthStatus({
state: 'about_to_retry',
nextState: oauthStatus.toRetry,
})
}
}
})
async function handleSubmitCode(value: string, url: string) {
try {
// Expecting format "authorizationCode#state" from the authorization callback URL
const [authorizationCode, state] = value.split('#')
if (!authorizationCode || !state) {
setOAuthStatus({
state: 'error',
message: 'Invalid code. Please make sure the full code was copied',
toRetry: { state: 'waiting_for_login', url },
})
return
}
// Track which path the user is taking (manual code entry)
logEvent('tengu_oauth_manual_entry', {})
oauthService.processCallback({
authorizationCode,
state,
useManualRedirect: true,
})
} catch (err) {
logError(err)
setOAuthStatus({
state: 'error',
message: (err as Error).message,
toRetry: { state: 'waiting_for_login', url },
})
}
}
const startOAuth = useCallback(async () => {
try {
const result = await oauthService
.startOAuthFlow(async url => {
setOAuthStatus({ state: 'waiting_for_login', url })
setTimeout(() => setShowPastePrompt(true), 3000)
})
.catch(err => {
// Handle token exchange errors specifically
if (err.message.includes('Token exchange failed')) {
setOAuthStatus({
state: 'error',
message:
'Failed to exchange authorization code for access token. Please try again.',
toRetry: { state: 'ready_to_start' },
})
logEvent('tengu_oauth_token_exchange_error', { error: err.message })
} else {
// Handle other errors
setOAuthStatus({
state: 'error',
message: err.message,
toRetry: { state: 'ready_to_start' },
})
}
throw err
})
setOAuthStatus({ state: 'creating_api_key' })
const apiKey = await createAndStoreApiKey(result.accessToken).catch(
err => {
setOAuthStatus({
state: 'error',
message: 'Failed to create API key: ' + err.message,
toRetry: { state: 'ready_to_start' },
})
logEvent('tengu_oauth_api_key_error', { error: err.message })
throw err
},
)
if (apiKey) {
setOAuthStatus({ state: 'success', apiKey })
sendNotification({ message: 'Kode login successful' })
} else {
setOAuthStatus({
state: 'error',
message:
"Unable to create API key. The server accepted the request but didn't return a key.",
toRetry: { state: 'ready_to_start' },
})
logEvent('tengu_oauth_api_key_error', {
error: 'server_returned_no_key',
})
}
} catch (err) {
const errorMessage = (err as Error).message
logEvent('tengu_oauth_error', { error: errorMessage })
}
}, [oauthService, setShowPastePrompt])
useEffect(() => {
if (oauthStatus.state === 'ready_to_start') {
startOAuth()
}
}, [oauthStatus.state, startOAuth])
// Helper function to render the appropriate status message
function renderStatusMessage(): React.ReactNode {
switch (oauthStatus.state) {
case 'idle':
return (
<Box flexDirection="column" gap={1}>
<Text bold>
{PRODUCT_NAME} is billed based on API usage through your Anthropic
Console account.
</Text>
<Box>
<Text>
Pricing may evolve as we move towards general availability.
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.permission}>
Press <Text bold>Enter</Text> to login to your Anthropic Console
account
</Text>
</Box>
</Box>
)
case 'waiting_for_login':
return (
<Box flexDirection="column" gap={1}>
{!showPastePrompt && (
<Box>
<SimpleSpinner />
<Text>Opening browser to sign in</Text>
</Box>
)}
{showPastePrompt && (
<Box>
<Text>{PASTE_HERE_MSG}</Text>
<TextInput
value={pastedCode}
onChange={setPastedCode}
onSubmit={(value: string) =>
handleSubmitCode(value, oauthStatus.url)
}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={textInputColumns}
/>
</Box>
)}
</Box>
)
case 'creating_api_key':
return (
<Box flexDirection="column" gap={1}>
<Box>
<SimpleSpinner />
<Text>Creating API key for Kode</Text>
</Box>
</Box>
)
case 'about_to_retry':
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.permission}>Retrying</Text>
</Box>
)
case 'success':
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.success}>
Login successful. Press <Text bold>Enter</Text> to continue
</Text>
</Box>
)
case 'error':
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.error}>OAuth error: {oauthStatus.message}</Text>
{oauthStatus.toRetry && (
<Box marginTop={1}>
<Text color={theme.permission}>
Press <Text bold>Enter</Text> to retry.
</Text>
</Box>
)}
</Box>
)
default:
return null
}
}
// We need to render the copy-able URL statically to prevent Ink <Text> from inserting
// newlines in the middle of the URL (this breaks Safari). Because <Static> components are
// only rendered once top-to-bottom, we also need to make everything above the URL static.
const staticItems: Record<string, React.JSX.Element> = {}
if (!isClearing) {
staticItems.header = (
<Box key="header" flexDirection="column" gap={1}>
<WelcomeBox />
<Box paddingBottom={1} paddingLeft={1}>
<AsciiLogo />
</Box>
</Box>
)
}
if (oauthStatus.state === 'waiting_for_login' && showPastePrompt) {
staticItems.urlToCopy = (
<Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}>
<Box paddingX={1}>
<Text dimColor>
Browser didn&apos;t open? Use the url below to sign in:
</Text>
</Box>
<Box width={1000}>
<Text dimColor>{oauthStatus.url}</Text>
</Box>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Static
items={Object.keys(staticItems)}
children={(item: string) => staticItems[item]}
/>
<Box paddingLeft={1} flexDirection="column" gap={1}>
{renderStatusMessage()}
</Box>
</Box>
)
}