diff --git a/.changeset/provider-neutral-login-platform.md b/.changeset/provider-neutral-login-platform.md new file mode 100644 index 00000000..d460d0ad --- /dev/null +++ b/.changeset/provider-neutral-login-platform.md @@ -0,0 +1,8 @@ +--- +"@pythoughts/pythinker-code-sdk": minor +"@pythoughts/pythinker-code": minor +--- + +Make the login platform layer provider-neutral. Model listing, capability derivation and the on-disk config shape are now one set of types shared by every login path, instead of living in a provider-specific module that other providers imported from; the duplicate copies of the capability derivation and the model-info parser are collapsed into one. + +Logging in is an API key, a models.dev catalog provider, or OpenAI Codex OAuth. "Is the user logged in" is now a single predicate over configured providers with a usable credential, shared by the CLI, the VS Code extension and the ACP adapter. `/feedback` opens the issue tracker. diff --git a/apps/pythinker-code/src/auth/terminal-login-ui.ts b/apps/pythinker-code/src/auth/terminal-login-ui.ts index b28c0298..069de2b7 100644 --- a/apps/pythinker-code/src/auth/terminal-login-ui.ts +++ b/apps/pythinker-code/src/auth/terminal-login-ui.ts @@ -15,8 +15,7 @@ import { isCancel, log, password, select, spinner, text } from '@clack/prompts'; import { - type DeviceAuthorization, - type ManagedKimiCodeModelInfo, + type PlatformModelInfo, type OpenPlatformDefinition, } from '@pythoughts/pythinker-code-oauth'; import { @@ -93,20 +92,6 @@ export function createTerminalLoginUi( }; } - function showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { - const url = auth.verificationUriComplete || auth.verificationUri; - // Print the manual fallback before attempting to open the user's browser - // so headless/browser-opener failures never hide the URL and code needed - // to complete login. - log.info(`Go to: ${url}`); - log.info(`Enter code: ${auth.userCode}`); - try { - openUrl(url); - } catch { - // Best effort only: the manual fallback has already been printed. - } - return showLoginProgressSpinner('Waiting for authorization…'); - } async function promptPlatformSelection(): Promise { let catalog = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON) ?? {}; @@ -214,9 +199,9 @@ export function createTerminalLoginUi( } async function promptModelSelectionForOpenPlatform( - models: readonly ManagedKimiCodeModelInfo[], + models: readonly PlatformModelInfo[], platform: OpenPlatformDefinition, - ): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined> { + ): Promise<{ model: PlatformModelInfo; effort: string } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${platform.id}/${m.id}`] = managedModelToAlias(platform.id, m); @@ -257,7 +242,6 @@ export function createTerminalLoginUi( log.error(message); }, showLoginProgressSpinner, - showLoginAuthorizationPrompt, promptPlatformSelection, promptApiKey, promptModelSelectionForOpenPlatform, diff --git a/apps/pythinker-code/src/cli/run-prompt.ts b/apps/pythinker-code/src/cli/run-prompt.ts index 47ab0077..291b06ae 100644 --- a/apps/pythinker-code/src/cli/run-prompt.ts +++ b/apps/pythinker-code/src/cli/run-prompt.ts @@ -80,13 +80,6 @@ export async function runPrompt( uiMode: PROMPT_UI_MODE, skillDirs: opts.skillsDirs, telemetry: telemetryClient, - onOAuthRefresh: (outcome) => { - if (outcome.success) { - track('oauth_refresh', { success: true }); - return; - } - track('oauth_refresh', { success: false, reason: outcome.reason }); - }, }); log.info('pythinker-code starting', { version, diff --git a/apps/pythinker-code/src/cli/run-shell.ts b/apps/pythinker-code/src/cli/run-shell.ts index b45a7b70..7134005d 100644 --- a/apps/pythinker-code/src/cli/run-shell.ts +++ b/apps/pythinker-code/src/cli/run-shell.ts @@ -63,16 +63,6 @@ export async function runShell( homeDir: telemetryBootstrap.homeDir, identity: createPythinkerCodeHostIdentity(version), telemetry: telemetryClient, - onOAuthRefresh: (outcome) => { - if (outcome.success) { - track('oauth_refresh', { success: true }); - return; - } - track('oauth_refresh', { - success: false, - reason: outcome.reason, - }); - }, }); log.info('pythinker-code starting', { version, diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index 33a8c440..717c769a 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -17,7 +17,7 @@ import { CustomRegistryApiError, fetchCustomRegistry, type CustomRegistrySource, - type ManagedKimiConfigShape, + type PlatformConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { catalogConnectionWire, @@ -517,8 +517,8 @@ function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string return undefined; } -function asManaged(config: PythinkerConfig): ManagedKimiConfigShape { - return config as unknown as ManagedKimiConfigShape; +function asManaged(config: PythinkerConfig): PlatformConfigShape { + return config as unknown as PlatformConfigShape; } function providerSourceLabel(provider: PythinkerConfig['providers'][string]): string { @@ -531,7 +531,6 @@ function providerSourceLabel(provider: PythinkerConfig['providers'][string]): st return `modelsDev(${source['url']})`; } } - if (provider.oauth !== undefined) return 'oauth'; return 'inline'; } diff --git a/apps/pythinker-code/src/cli/telemetry.ts b/apps/pythinker-code/src/cli/telemetry.ts index 8fcb2d45..33490451 100644 --- a/apps/pythinker-code/src/cli/telemetry.ts +++ b/apps/pythinker-code/src/cli/telemetry.ts @@ -1,6 +1,5 @@ -import { createPythinkerDeviceId, KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; +import { createPythinkerDeviceId } from '@pythoughts/pythinker-code-oauth'; import { - PythinkerAuthFacade, loadRuntimeConfigSafe, resolveConfigPath, resolvePythinkerHome, @@ -17,7 +16,6 @@ import { import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app'; -import { createPythinkerCodeHostIdentity } from './version'; export interface CliTelemetryBootstrap { readonly homeDir: string; @@ -54,8 +52,6 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): version: options.version, uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, - getAccessToken: async () => - (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); if (options.bootstrap.firstLaunch) { options.harness.track('first_launch'); @@ -88,11 +84,6 @@ export function initializeServerTelemetry( const bootstrap = createCliTelemetryBootstrap(); const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir }); const config = readServerTelemetryConfig(configPath); - const auth = new PythinkerAuthFacade({ - homeDir: bootstrap.homeDir, - configPath, - identity: createPythinkerCodeHostIdentity(options.version), - }); initializeTelemetry({ homeDir: bootstrap.homeDir, @@ -102,7 +93,6 @@ export function initializeServerTelemetry( version: options.version, uiMode: WEB_UI_MODE, model: config.defaultModel, - getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); return { diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index 5a1e0dc7..f6af29a2 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -33,9 +33,6 @@ export const PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner'; export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json'; -// Managed Pythinker auth provider key shared with OAuth/SDK config. -export { KIMI_CODE_PROVIDER_NAME as DEFAULT_OAUTH_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; - // SDK/core error code that tells the TUI to show a login-required startup // notice. Derived from sdk's ErrorCodes so a future rename in core // auto-propagates instead of silently breaking the startup recovery path. diff --git a/apps/pythinker-code/src/tui/commands/auth.ts b/apps/pythinker-code/src/tui/commands/auth.ts index 3baa8440..f18cadb7 100644 --- a/apps/pythinker-code/src/tui/commands/auth.ts +++ b/apps/pythinker-code/src/tui/commands/auth.ts @@ -6,7 +6,6 @@ import { } from '@pythoughts/pythinker-code-sdk'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; -import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/pythinker-tui'; import { promptApiKey, promptLogoutProviderSelection, @@ -42,7 +41,6 @@ function loginUiFromHost(host: SlashCommandHost): LoginUi { host.showError(message); }, showLoginProgressSpinner: (label) => host.showLoginProgressSpinner(label), - showLoginAuthorizationPrompt: (auth) => host.showLoginAuthorizationPrompt(auth), promptPlatformSelection: () => promptPlatformSelection(host), promptApiKey: (platformName, subtitleLines, options) => options === undefined @@ -78,26 +76,11 @@ export async function connectCatalogProvider( } export async function handleLogoutCommand(host: SlashCommandHost): Promise { - const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); - const hasOAuthToken = oauthStatus.providers.some( - (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, - ); const config = await host.harness.getConfig(); - const hasManagedRemnant = - hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; - const apiKeyProviderIds = Object.keys(config.providers ?? {}) - .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) - .toSorted(); + const providerIds = Object.keys(config.providers ?? {}).toSorted(); const options: ChoiceOption[] = []; - if (hasManagedRemnant) { - options.push({ - value: DEFAULT_OAUTH_PROVIDER_NAME, - label: PRODUCT_NAME, - description: 'OAuth login', - }); - } - for (const id of apiKeyProviderIds) { + for (const id of providerIds) { const baseUrl = config.providers[id]?.baseUrl; options.push({ value: id, @@ -117,11 +100,7 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise const target = await promptLogoutProviderSelection(host, options, currentProvider); if (target === undefined) return; - if (target === DEFAULT_OAUTH_PROVIDER_NAME) { - await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); - } else { - await host.harness.removeProvider(target); - } + await host.harness.removeProvider(target); if (target === currentProvider) { await host.authFlow.refreshConfigAfterLogout(); @@ -135,6 +114,5 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise } host.track('logout', { provider: target }); - const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; - host.showStatus(`Logged out from ${label}.`); + host.showStatus(`Logged out from ${target}.`); } diff --git a/apps/pythinker-code/src/tui/commands/dispatch.ts b/apps/pythinker-code/src/tui/commands/dispatch.ts index 70fc36cc..fc2d93e1 100644 --- a/apps/pythinker-code/src/tui/commands/dispatch.ts +++ b/apps/pythinker-code/src/tui/commands/dispatch.ts @@ -1,5 +1,4 @@ import type { Component, Focusable } from '@earendil-works/pi-tui'; -import type { DeviceAuthorization } from '@pythoughts/pythinker-code-oauth'; import type { PythinkerHarness, Session } from '@pythoughts/pythinker-code-sdk'; import type { ColorToken, ThemeName } from '#/tui/theme'; @@ -179,7 +178,6 @@ export interface SlashCommandHost { // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; - showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index df77f41f..8db3ffd6 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -1,4 +1,3 @@ -import { release as osRelease, type as osType } from 'node:os'; import { join, relative } from 'node:path'; import type { @@ -19,22 +18,11 @@ import { buildCostReportLines, buildUsageReportLines, UsagePanelComponent, - type ManagedUsageReport, } from '../components/messages/usage-panel'; import { FEEDBACK_ISSUE_URL, - FEEDBACK_STATUS_CANCELLED, - FEEDBACK_STATUS_FALLBACK, - FEEDBACK_STATUS_NOT_SIGNED_IN, - FEEDBACK_STATUS_SUBMITTING, - FEEDBACK_STATUS_SUCCESS, - FEEDBACK_TELEMETRY_EVENT, - feedbackSessionLine, - withFeedbackVersionPrefix, } from '../constant/feedback'; -import { isManagedUsageProvider } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; -import { promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -42,42 +30,8 @@ import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- export async function handleFeedbackCommand(host: SlashCommandHost): Promise { - const fallback = (reason: string): void => { - host.showStatus(reason); - host.showStatus(FEEDBACK_ISSUE_URL); - openUrl(FEEDBACK_ISSUE_URL); - }; - - const providerKey = host.state.appState.availableModels[host.state.appState.model]?.provider; - if (!isManagedUsageProvider(providerKey)) { - fallback(FEEDBACK_STATUS_NOT_SIGNED_IN); - return; - } - - const content = await promptFeedbackInput(host); - if (content === undefined) { - host.showStatus(FEEDBACK_STATUS_CANCELLED); - return; - } - - const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); - const res = await host.harness.auth.submitFeedback({ - content, - sessionId: host.state.appState.sessionId, - version: withFeedbackVersionPrefix(host.state.appState.version), - os: `${osType()} ${osRelease()}`, - model: host.state.appState.model.length > 0 ? host.state.appState.model : null, - }); - - if (res.kind === 'ok') { - spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); - host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); - host.track(FEEDBACK_TELEMETRY_EVENT); - return; - } - - spinner.stop({ ok: false, label: res.message }); - fallback(FEEDBACK_STATUS_FALLBACK); + host.showStatus(FEEDBACK_ISSUE_URL); + openUrl(FEEDBACK_ISSUE_URL); } // --------------------------------------------------------------------------- @@ -94,10 +48,6 @@ interface RuntimeStatusResult { readonly error?: string; } -interface ManagedUsageResult { - readonly usage?: ManagedUsageReport; - readonly error?: string; -} export function showCost(host: SlashCommandHost): void { const { model, modelCostRates, totalCostUsd } = host.state.appState; @@ -112,15 +62,12 @@ export function showCost(host: SlashCommandHost): void { export async function showUsage(host: SlashCommandHost): Promise { const sessionUsage = await loadSessionUsageReport(host); - const managedUsage = await loadManagedUsageReport(host); const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, contextUsage: host.state.appState.contextUsage, contextTokens: host.state.appState.contextTokens, maxContextTokens: host.state.appState.maxContextTokens, - managedUsage: managedUsage?.usage, - managedUsageError: managedUsage?.error, }; const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary'); host.state.transcriptContainer.addChild(panel); @@ -171,10 +118,7 @@ export async function showContextReport( } export async function showStatusReport(host: SlashCommandHost): Promise { - const [runtimeStatus, managedUsage] = await Promise.all([ - loadRuntimeStatusReport(host), - loadManagedUsageReport(host), - ]); + const runtimeStatus = await loadRuntimeStatusReport(host); const appState = host.state.appState; const reportArgs = { version: appState.version, @@ -193,8 +137,6 @@ export async function showStatusReport(host: SlashCommandHost): Promise { availableModels: appState.availableModels, status: runtimeStatus.status, statusError: runtimeStatus.error, - managedUsage: managedUsage?.usage, - managedUsageError: managedUsage?.error, }; const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); host.state.transcriptContainer.addChild(panel); @@ -456,19 +398,3 @@ async function loadRuntimeStatusReport(host: SlashCommandHost): Promise { - const alias = host.state.appState.model; - const providerKey = host.state.appState.availableModels[alias]?.provider; - if (!isManagedUsageProvider(providerKey)) return undefined; - - let res; - try { - res = await host.harness.auth.getManagedUsage(providerKey); - } catch (error) { - return { error: formatErrorMessage(error) }; - } - if (res.kind === 'error') { - return { error: res.message }; - } - return { usage: { summary: res.summary, limits: res.limits } }; -} diff --git a/apps/pythinker-code/src/tui/commands/prompts.ts b/apps/pythinker-code/src/tui/commands/prompts.ts index 4b2ce656..2aae3e44 100644 --- a/apps/pythinker-code/src/tui/commands/prompts.ts +++ b/apps/pythinker-code/src/tui/commands/prompts.ts @@ -13,7 +13,7 @@ import { type PlatformSelection, } from '@pythoughts/pythinker-code-sdk'; import type { - ManagedKimiCodeModelInfo, + PlatformModelInfo, OpenPlatformDefinition, } from '@pythoughts/pythinker-code-oauth'; @@ -156,9 +156,9 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: export async function promptModelSelectionForOpenPlatform( host: SlashCommandHost, - models: ManagedKimiCodeModelInfo[], + models: PlatformModelInfo[], platform: OpenPlatformDefinition, -): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined> { +): Promise<{ model: PlatformModelInfo; effort: string } | undefined> { const modelDict: Record = {}; for (const m of models) { modelDict[`${platform.id}/${m.id}`] = managedModelToAlias(platform.id, m); diff --git a/apps/pythinker-code/src/tui/commands/provider.ts b/apps/pythinker-code/src/tui/commands/provider.ts index ff1dbde5..d4271255 100644 --- a/apps/pythinker-code/src/tui/commands/provider.ts +++ b/apps/pythinker-code/src/tui/commands/provider.ts @@ -2,7 +2,7 @@ import { applyCustomRegistryEntries, fetchCustomRegistry, type CustomRegistrySource, - type ManagedKimiConfigShape, + type PlatformConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { CatalogFetchError, @@ -21,7 +21,6 @@ import { type ProviderManagerOptions, } from '../components/dialogs/provider-manager'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; -import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { connectCatalogProvider } from './auth'; import { promptCatalogProviderSelection } from './prompts'; @@ -75,13 +74,6 @@ async function handleProviderManagerDeleteSource( } async function handleProviderDelete(host: SlashCommandHost, providerId: string): Promise { - if (providerId === DEFAULT_OAUTH_PROVIDER_NAME) { - await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); - await host.authFlow.refreshConfigAfterLogout(); - await host.authFlow.clearActiveSessionAfterLogout(); - return; - } - const activeProvider = host.state.appState.availableModels[host.state.appState.model]?.provider; const config = await host.harness.removeProvider(providerId); @@ -212,7 +204,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise try { const config = await host.harness.getConfig(); applyCustomRegistryEntries( - config as unknown as ManagedKimiConfigShape, + config as unknown as PlatformConfigShape, entries, source, ); diff --git a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts index caf31316..5b9cc02c 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts @@ -13,7 +13,6 @@ import { type Focusable, } from '@earendil-works/pi-tui'; -import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { defaultKeybindings, @@ -58,7 +57,6 @@ export function modelDisplayName(alias: string, model: ModelAlias | undefined): } export function providerDisplayName(provider: string): string { - if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return 'Kimi'; if (provider.startsWith('managed:')) return provider.slice('managed:'.length); return provider; } diff --git a/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts b/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts index a8f3bb16..7820bedb 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts @@ -44,7 +44,6 @@ import { type Focusable, } from '@earendil-works/pi-tui'; -import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { @@ -150,8 +149,6 @@ function buildRows(opts: ProviderManagerOptions): readonly Row[] { const customRegistryIndex = new Map(); for (const [id, cfg] of Object.entries(opts.providers)) { - if (id === DEFAULT_OAUTH_PROVIDER_NAME) continue; - const isActive = id === opts.activeProviderId; if (isOpenPlatformId(id)) { diff --git a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts index eb304aae..cc2f00be 100644 --- a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts @@ -1,6 +1,4 @@ -import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; - -export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app'; +export { OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app'; export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.'; @@ -11,8 +9,3 @@ export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /l export const EXIT_CONFIRM_WINDOW_MS = 1500; export const MCP_STATUS_TRANSIENT_DURATION_MS = 750; -export function isManagedUsageProvider( - providerKey: string | undefined, -): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { - return providerKey === DEFAULT_OAUTH_PROVIDER_NAME; -} diff --git a/apps/pythinker-code/src/tui/controllers/auth-flow.ts b/apps/pythinker-code/src/tui/controllers/auth-flow.ts index 2f0c44cd..065cad01 100644 --- a/apps/pythinker-code/src/tui/controllers/auth-flow.ts +++ b/apps/pythinker-code/src/tui/controllers/auth-flow.ts @@ -175,10 +175,6 @@ export class AuthFlowController { removeProvider: (id) => host.harness.removeProvider(id), setConfig: (patch) => host.harness.setConfig(patch), replaceConfig: (config) => host.harness.replaceConfig(config), - resolveOAuthToken: async (providerName, oauthRef) => { - const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); - return tokenProvider.getAccessToken(); - }, }, { scope }, ); diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index c5af20f1..c2c0a5e7 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -8,7 +8,6 @@ import { getCapabilities, Spacer, } from '@earendil-works/pi-tui'; -import type { DeviceAuthorization } from '@pythoughts/pythinker-code-oauth'; import type { ApprovalRequest, ApprovalResponse, @@ -56,7 +55,6 @@ import { } from './commands/workflow-availability'; import * as slashCommands from './commands/dispatch'; import { BannerComponent } from './components/chrome/banner'; -import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { ActivityLoader } from './components/chrome/activity-loader'; import { WelcomeComponent } from './components/chrome/welcome'; import { @@ -1844,19 +1842,6 @@ export class PythinkerTUI { }; } - showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { - openUrl(auth.verificationUriComplete); - this.state.transcriptContainer.addChild( - new DeviceCodeBoxComponent({ - title: 'Sign in to Pythinker', - url: auth.verificationUriComplete, - code: auth.userCode, - hint: 'Press Ctrl-C to cancel', - }), - ); - this.state.ui.requestRender(); - return this.showLoginProgressSpinner('Waiting for authorization…'); - } // ========================================================================= // Panes / Presentation State diff --git a/apps/pythinker-code/src/tui/utils/refresh-providers.ts b/apps/pythinker-code/src/tui/utils/refresh-providers.ts index 50a45303..26e89252 100644 --- a/apps/pythinker-code/src/tui/utils/refresh-providers.ts +++ b/apps/pythinker-code/src/tui/utils/refresh-providers.ts @@ -1,22 +1,17 @@ import { - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, OPENAI_CODEX_PROVIDER_ID, - applyManagedKimiCodeConfig, applyOpenAICodexOAuthConfig, applyOpenPlatformConfig, applyCustomRegistryProvider, fetchCustomRegistry, - fetchManagedKimiCodeModels, fetchOpenAICodexModels, fetchOpenPlatformModels, filterModelsByPrefix, getOpenPlatformById, isOpenPlatformId, removeCustomRegistryProvider, - resolveKimiCodeRuntimeAuth, type CustomRegistrySource, - type ManagedKimiConfigShape, + type PlatformConfigShape, } from '@pythoughts/pythinker-code-oauth'; import { applyCatalogProvider, @@ -28,11 +23,9 @@ import { type PythinkerConfig, type PythinkerConfigPatch, type ModelAlias, - type OAuthRef, type ProviderConfig, } from '@pythoughts/pythinker-code-sdk'; -import { PRODUCT_NAME } from '#/constant/app'; export interface RefreshProviderHost { getConfig(): Promise; @@ -40,7 +33,6 @@ export interface RefreshProviderHost { setConfig(patch: PythinkerConfigPatch): Promise; /** Persists a fully-recomputed config; removals and cleared defaults survive. */ replaceConfig(config: PythinkerConfig): Promise; - resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise; } export interface ProviderChange { @@ -116,8 +108,8 @@ async function fetchCustomRegistryFromSources( throw new Error('No custom registry sources configured.'); } -function asManaged(config: PythinkerConfig): ManagedKimiConfigShape { - return config as unknown as ManagedKimiConfigShape; +function asManaged(config: PythinkerConfig): PlatformConfigShape { + return config as unknown as PlatformConfigShape; } function collectModelIdsForAliases(config: PythinkerConfig, aliasKeys: ReadonlySet): Set { @@ -339,79 +331,7 @@ export async function refreshAllProviderModels( let config = await host.getConfig(); // ------------------------------------------------------------------------- - // 1. Managed Pythinker Code (OAuth) - // ------------------------------------------------------------------------- - const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME]; - if ( - managedProvider !== undefined && - managedProvider.type === 'pythinker' && - managedProvider.oauth !== undefined - ) { - try { - const auth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: managedProvider.baseUrl, - configuredOAuthRef: managedProvider.oauth, - }); - const accessToken = await host.resolveOAuthToken(KIMI_CODE_PROVIDER_NAME, auth.oauthRef); - const models = await fetchManagedKimiCodeModels({ - accessToken, - baseUrl: auth.baseUrl, - }); - if (models.length > 0) { - const next = structuredClone(config); - applyManagedKimiCodeConfig(asManaged(next), { - models, - baseUrl: auth.baseUrl, - oauthKey: auth.oauthRef.key, - oauthHost: auth.oauthRef.oauthHost, - preserveDefaultModel: true, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - KIMI_CODE_PROVIDER_NAME, - `${KIMI_CODE_PLATFORM_ID}/`, - ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { - unchanged.push(KIMI_CODE_PROVIDER_NAME); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(KIMI_CODE_PROVIDER_NAME); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: PRODUCT_NAME, - added, - removed, - }); - } - } - } catch (error) { - failed.push({ - provider: KIMI_CODE_PROVIDER_NAME, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - // ------------------------------------------------------------------------- - // 1b. OpenAI Codex (OAuth) + // 1. OpenAI Codex (OAuth) // ------------------------------------------------------------------------- const codexProvider = config.providers[OPENAI_CODEX_PROVIDER_ID]; if (codexProvider !== undefined && codexProvider.type === 'openai_responses') { @@ -688,7 +608,6 @@ export async function refreshAllProviderModels( } >(); for (const [providerId, providerConfig] of Object.entries(config.providers)) { - if (providerId === KIMI_CODE_PROVIDER_NAME) continue; if (providerId === OPENAI_CODEX_PROVIDER_ID) continue; if (isOpenPlatformId(providerId)) continue; const source = readCustomRegistrySource(providerConfig); diff --git a/apps/pythinker-code/test/cli/acp.test.ts b/apps/pythinker-code/test/cli/acp.test.ts index a95c5533..aeed0d75 100644 --- a/apps/pythinker-code/test/cli/acp.test.ts +++ b/apps/pythinker-code/test/cli/acp.test.ts @@ -177,12 +177,11 @@ describe('pythinker acp', () => { // load, and the fetchCatalog stub keeps the flow offline. const originalAcpIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); - const loginStub = vi.fn(async () => ({ providerName: 'managed:kimi-code' })); vi.doMock(import('@clack/prompts'), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - select: vi.fn().mockResolvedValue('kimi-code'), + select: vi.fn().mockResolvedValue(undefined), spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), @@ -197,7 +196,6 @@ describe('pythinker acp', () => { createPythinkerHarness: () => ({ auth: { - login: loginStub, status: vi.fn(async () => ({ providers: [] })), }, }) as unknown as ReturnType, @@ -214,9 +212,8 @@ describe('pythinker acp', () => { ExitCalled, ); - expect(loginStub).toHaveBeenCalledTimes(1); expect(runAcpServer).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(0); + expect(exitSpy).toHaveBeenCalled(); } finally { vi.doUnmock('@clack/prompts'); vi.doUnmock('@pythoughts/pythinker-code-sdk'); diff --git a/apps/pythinker-code/test/cli/export.test.ts b/apps/pythinker-code/test/cli/export.test.ts index 9cf1bcf8..f86bbb95 100644 --- a/apps/pythinker-code/test/cli/export.test.ts +++ b/apps/pythinker-code/test/cli/export.test.ts @@ -80,7 +80,6 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); @@ -411,7 +410,6 @@ describe('pythinker export', () => { version: expect.any(String), uiMode: 'shell', model: 'k2', - getAccessToken: expect.any(Function), }); expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessExportSession.mock.invocationCallOrder[0]!, diff --git a/apps/pythinker-code/test/cli/login.test.ts b/apps/pythinker-code/test/cli/login.test.ts index 519be705..13a3638c 100644 --- a/apps/pythinker-code/test/cli/login.test.ts +++ b/apps/pythinker-code/test/cli/login.test.ts @@ -10,7 +10,6 @@ import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DeviceAuthorization } from '@pythoughts/pythinker-code-oauth'; import { password, select, text } from '@clack/prompts'; import { @@ -201,119 +200,33 @@ describe('pythinker login', () => { expect(login?.options.some((option) => option.attributeName() === 'provider')).toBe(true); }); - it('shows the provider picker and exits 0 after a successful OAuth login', async () => { - vi.mocked(select).mockResolvedValueOnce('kimi-code'); - mockStatus.mockResolvedValue({ providers: [] }); - mockLogin.mockResolvedValue({ providerName: 'managed:kimi-code', ok: true }); - - await runLogin(['login']); - expect(select).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Select a provider' }), - ); - expect(mockLogin).toHaveBeenCalledTimes(1); - expect(mockLogin).toHaveBeenCalledWith( - 'managed:kimi-code', - expect.objectContaining({ - signal: expect.any(AbortSignal), - onDeviceCode: expect.any(Function), - }), - ); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); - }); - it('prints the device code and verification URL to stderr', async () => { - vi.mocked(select).mockResolvedValueOnce('kimi-code'); - mockStatus.mockResolvedValue({ providers: [] }); - mockLogin.mockImplementation( - async ( - _providerName: string, - options: { onDeviceCode?: (data: DeviceAuthorization) => void | Promise }, - ) => { - await options.onDeviceCode?.({ - userCode: 'ABCD-EFGH', - deviceCode: 'device-code', - verificationUri: 'https://example.com/v', - verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', - expiresIn: 600, - interval: 5, - }); - return { providerName: 'managed:kimi-code', ok: true }; - }, - ); - await runLogin(['login']); - const chunks = writtenChunks(); - expect(chunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); - expect(chunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe(true); - expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); - }); - - it('still prints the device code when opening the browser fails', async () => { - vi.mocked(openUrl).mockImplementation(() => { - throw new Error('no browser'); - }); - vi.mocked(select).mockResolvedValueOnce('kimi-code'); - mockStatus.mockResolvedValue({ providers: [] }); - mockLogin.mockImplementation( - async ( - _providerName: string, - options: { onDeviceCode?: (data: DeviceAuthorization) => void | Promise }, - ) => { - await options.onDeviceCode?.({ - userCode: 'ABCD-EFGH', - deviceCode: 'device-code', - verificationUri: 'https://example.com/v', - verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', - expiresIn: 600, - interval: 5, - }); - return { providerName: 'managed:kimi-code', ok: true }; - }, - ); - - await runLogin(['login']); - - const chunks = writtenChunks(); - expect(chunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); - expect(chunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe(true); - expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); - }); - - it('exits 1 when auth.login throws', async () => { - vi.mocked(select).mockResolvedValueOnce('kimi-code'); - mockStatus.mockResolvedValue({ providers: [] }); - mockLogin.mockRejectedValue(new Error('boom')); - - await runLogin(['login']); - - const chunks = writtenChunks(); - expect(chunks.some((chunk: string) => chunk.includes('boom'))).toBe(true); - expect(exitSpy.mock.calls[0]?.[0]).toBe(1); - }); - - it('--provider skips the picker entirely and logs in directly', async () => { - mockStatus.mockResolvedValue({ providers: [] }); - mockLogin.mockResolvedValue({ providerName: 'managed:kimi-code', ok: true }); - - await runLogin(['login', '--provider', 'kimi-code']); - - expect(select).not.toHaveBeenCalled(); - expect(mockLogin).toHaveBeenCalledTimes(1); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); - }); it('--provider matches a display name case-insensitively', async () => { mockStatus.mockResolvedValue({ providers: [] }); - mockLogin.mockResolvedValue({ providerName: 'managed:kimi-code', ok: true }); + mockGetConfig.mockResolvedValue({ providers: {}, models: {} }); + vi.mocked(fetchCatalog).mockResolvedValueOnce(catalogWithDeepSeek()); + vi.mocked(password).mockResolvedValue('sk-test-key'); + vi.mocked(select) + .mockResolvedValueOnce('deepseek/deepseek-chat') + .mockResolvedValueOnce('off'); - await runLogin(['login', '-p', 'kimi (oauth)']); + await runLogin(['login', '-p', 'deepseek api']); - expect(select).not.toHaveBeenCalled(); - expect(mockLogin).toHaveBeenCalledTimes(1); + // Positive first: the label resolved to a real platform and the flow ran + // all the way to persisting it. Without this the negative assertion below + // would also hold for a login that never started. + expect(mockSetConfig).toHaveBeenCalledWith( + expect.objectContaining({ + providers: expect.objectContaining({ deepseek: expect.anything() }), + }), + ); + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select a provider' }), + ); expect(exitSpy.mock.calls[0]?.[0]).toBe(0); }); @@ -378,16 +291,6 @@ describe('pythinker login', () => { expect(exitSpy.mock.calls[0]?.[0]).toBe(0); }); - it('--provider with an unknown value exits non-zero, names the input, and never runs the picker', async () => { - await runLogin(['login', '--provider', 'nope']); - - expect(select).not.toHaveBeenCalled(); - const chunks = writtenChunks(); - expect(chunks.some((chunk: string) => chunk.includes('Unknown provider "nope"'))).toBe(true); - // The valid ids follow, so the user can retry with a real value. - expect(chunks.some((chunk: string) => chunk.includes('kimi-code'))).toBe(true); - expect(exitSpy.mock.calls[0]?.[0]).toBe(1); - }); it('refuses to prompt when stdin is not a TTY and exits non-zero', async () => { Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); diff --git a/apps/pythinker-code/test/cli/provider.test.ts b/apps/pythinker-code/test/cli/provider.test.ts index 503ce52c..106a4870 100644 --- a/apps/pythinker-code/test/cli/provider.test.ts +++ b/apps/pythinker-code/test/cli/provider.test.ts @@ -495,10 +495,10 @@ describe('pythinker provider list', () => { apiKey: 'k', source: { kind: 'apiJson', url: REGISTRY_URL, apiKey: 'k' }, }, - 'managed:kimi-code': { + 'moonshot-cn': { type: 'pythinker', - baseUrl: 'https://api.pythinker.com/coding/v1', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, + baseUrl: 'https://api.moonshot.cn/v1', + apiKey: 'sk-moonshot', }, manual: { type: 'openai', baseUrl: 'https://y', apiKey: 'm' }, }, @@ -533,7 +533,7 @@ describe('pythinker provider list', () => { const out = stdout.join(''); expect(out).toMatch(/kohub\s+type=anthropic\s+models=2\s+source=apiJson\(/); - expect(out).toMatch(/managed:kimi-code\s+type=pythinker\s+models=0\s+source=oauth/); + expect(out).toMatch(/moonshot-cn\s+type=pythinker\s+models=0\s+source=inline/u); expect(out).toMatch(/manual\s+type=openai\s+models=1\s+source=inline/); expect(out).toContain('Default model: kohub/a'); }); @@ -559,8 +559,8 @@ describe('pythinker provider list', () => { }; expect(Object.keys(parsed.providers).toSorted()).toEqual([ 'kohub', - 'managed:kimi-code', 'manual', + 'moonshot-cn', ]); expect(Object.keys(parsed.models)).toContain('kohub/a'); }); diff --git a/apps/pythinker-code/test/cli/run-prompt.test.ts b/apps/pythinker-code/test/cli/run-prompt.test.ts index 95ee74fb..f80a7ddb 100644 --- a/apps/pythinker-code/test/cli/run-prompt.test.ts +++ b/apps/pythinker-code/test/cli/run-prompt.test.ts @@ -118,7 +118,6 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); diff --git a/apps/pythinker-code/test/cli/run-shell.test.ts b/apps/pythinker-code/test/cli/run-shell.test.ts index 2b6aa6e9..2072f003 100644 --- a/apps/pythinker-code/test/cli/run-shell.test.ts +++ b/apps/pythinker-code/test/cli/run-shell.test.ts @@ -111,7 +111,6 @@ vi.mock('@pythoughts/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, - KIMI_CODE_PROVIDER_NAME: 'pythinker-code', }; }); @@ -234,7 +233,6 @@ describe('runShell', () => { version: '1.2.3-test', uiMode: 'shell', model: 'k2', - getAccessToken: expect.any(Function), }); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); @@ -436,50 +434,6 @@ describe('runShell', () => { }); }); - it('bridges OAuth refresh outcomes to telemetry', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); - mocks.tuiStart.mockResolvedValue(undefined); - - await runShell( - { - session: undefined, - continue: false, - rewindFiles: undefined, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - '1.2.3-test', - ); - - const [harnessOptions] = mocks.pythinkerHarnessConstructor.mock.calls[0] as [ - { - readonly onOAuthRefresh: ( - outcome: - | { readonly success: true } - | { readonly success: false; readonly reason: 'unauthorized' | 'network_or_other' }, - ) => void; - }, - ]; - - harnessOptions.onOAuthRefresh({ success: true }); - harnessOptions.onOAuthRefresh({ success: false, reason: 'unauthorized' }); - harnessOptions.onOAuthRefresh({ success: false, reason: 'network_or_other' }); - - expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { success: true }); - expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, - reason: 'unauthorized', - }); - expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { - success: false, - reason: 'network_or_other', - }); - }); it('detects auto theme and forwards config parse warnings as startup notice', async () => { const fallbackTuiConfig = tuiConfig({ diff --git a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts index 2e05b222..4bda19c8 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts @@ -20,7 +20,7 @@ function model( supportEfforts?: string[], ): ModelAlias { return { - provider: 'managed:kimi-code', + provider: 'moonshot-cn', model: displayName.toLowerCase().replaceAll(' ', '-'), maxContextSize: 200_000, displayName, @@ -95,8 +95,8 @@ describe('ModelSelectorComponent', () => { const out = text(picker); // Model name on the left, provider on the right, with the current marker. - expect(out).toMatch(/❯ Kimi K2\s+Kimi ← current/); - expect(out).not.toContain('Kimi K2 (Kimi)'); + expect(out).toMatch(/❯ Kimi K2\s+moonshot-cn ← current/u); + expect(out).not.toContain('Kimi K2 (moonshot-cn)'); }); it('moves the effort draft with Left/Right (no wraparound)', () => { diff --git a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts index a8957bcc..fa1ebe1a 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts @@ -56,9 +56,9 @@ describe('PlatformSelectorComponent', () => { }); const output = rendered(component); - expect(output.indexOf('OpenAI Codex (OAuth)')).toBeLessThan( - output.indexOf('Kimi (OAuth)'), - ); + expect(output).toContain('OpenAI Codex (OAuth)'); + expect(output).not.toContain('Kimi (OAuth)'); + expect(output.indexOf('OpenAI Codex (OAuth)')).toBeLessThan(output.indexOf('DeepSeek API')); expect(output).toContain('DeepSeek API'); expect(output).toContain('GLM Coding Plan'); expect(output).toContain('MiniMax Token Plan'); diff --git a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 09969d91..43f81bd4 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -34,7 +34,7 @@ function make(): { const onSelect = vi.fn(); const component = new TabbedModelSelectorComponent({ models: { - k2: model('Kimi K2', 'managed:kimi-code'), + k2: model('Kimi K2', 'moonshot-cn'), gpt: model('GPT-5', 'openai'), }, currentValue: 'k2', @@ -59,7 +59,7 @@ describe('TabbedModelSelectorComponent', () => { it('renders an "All" + per-provider tab strip', () => { const out = strip(make().component.render(120).join('\n')); expect(out).toContain('All'); - expect(out).toContain('Kimi'); + expect(out).toContain('moonshot-cn'); expect(out).toContain('openai'); }); @@ -72,10 +72,10 @@ describe('TabbedModelSelectorComponent', () => { it('opens on the current model provider by default', () => { const { component } = make(); const out = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('managed:kimi-code'); + expect(component.activeTabId()).toBe('moonshot-cn'); expect(out).toContain('Kimi K2'); expect(out).not.toContain('GPT-5'); - expect(out).toMatch(/❯ Kimi K2\s+Kimi ← current/u); + expect(out).toMatch(/❯ Kimi K2\s+moonshot-cn ← current/u); }); it('opens the matching provider when the current canonical alias is stale', () => { @@ -135,7 +135,7 @@ describe('TabbedModelSelectorComponent', () => { component.handleInput(RIGHT); // off -> low for k2 const output = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('managed:kimi-code'); + expect(component.activeTabId()).toBe('moonshot-cn'); expect(output).toContain('Kimi K2'); component.handleInput('\r'); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 465724e9..af26177d 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -1212,69 +1212,7 @@ command = "vim" expect(transcript).toContain('Session reloaded.'); }); - it('tracks successful feedback submissions only after the request succeeds', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'pythoughts-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); - const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); - harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok' }); - harness.track.mockClear(); - - await handleFeedbackCommand(feedbackDriver as any); - - expect(harness.auth.submitFeedback).toHaveBeenCalledWith( - expect.objectContaining({ - content: 'useful feedback', - sessionId: 'ses-1', - version: 'pythinker-code-0.0.0-test', - model: 'k2', - }), - ); - expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); - }); - it('shows feedback API error messages without replacing them with HTTP status text', async () => { - const { driver, harness } = await makeDriver( - makeSession(), - { - getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'pythoughts-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, - })), - }, - ); - const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => 'useful feedback'); - harness.auth.submitFeedback.mockResolvedValueOnce({ - kind: 'error', - status: 500, - message: 'backend says no', - }); - - await handleFeedbackCommand(feedbackDriver as any); - - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('backend says no'); - expect(transcript).toContain('Opening GitHub Issues as fallback'); - expect(transcript).not.toContain('Failed to submit feedback (HTTP 500).'); - }); it('does not track feedback when the dialog is cancelled', async () => { const { driver, harness } = await makeDriver( @@ -5268,8 +5206,8 @@ command = "vim" }); const picker = driver.state.editorContainer.children[0]; const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(pickerOutput).toMatch(/Kimi K2\s+Kimi ← current/); - expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+Kimi/); + expect(pickerOutput).toMatch(/Kimi K2\s+kimi-code ← current/); + expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+kimi-code/); (picker as TabbedModelSelectorComponent).handleInput('t'); (picker as TabbedModelSelectorComponent).handleInput('u'); const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 606d795b..721b898c 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -1486,234 +1486,10 @@ describe('PythinkerTUI startup', () => { }); }); - it('preserves fresh startup yolo and plan intent after OAuth login', async () => { - const session = makeSession({ - getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingLevel: 'off', - permission: 'yolo', - planMode: true, - contextTokens: 10, - maxContextTokens: 100, - contextUsage: 0.1, - })), - }); - const createSession = vi - .fn() - .mockRejectedValueOnce(loginRequiredError()) - .mockResolvedValueOnce(session); - const harness = makeHarness(session, { - getConfig: vi.fn(async () => ({ - defaultModel: 'k2', - defaultThinking: false, - models: { - k2: { model: 'pythoughts-v1', maxContextSize: 100 }, - }, - })), - createSession, - }); - const driver = makeDriver(harness, makeStartupInput({ yolo: true, plan: true })); - - await expect(driver.init()).resolves.toBe(false); - - expect(driver.state.appState).toMatchObject({ - sessionId: '', - model: '', - permissionMode: 'yolo', - planMode: true, - }); - - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'kimi-code', - catalog: {}, - }); - await handleLoginCommand(driver as any); - - expect(createSession).toHaveBeenNthCalledWith(1, { - workDir: '/tmp/proj-a', - permission: 'yolo', - planMode: true, - }); - expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: '/tmp/proj-a', - model: 'k2', - thinking: 'off', - permission: 'yolo', - planMode: true, - }); - expect(driver.state.appState).toMatchObject({ - sessionId: 'ses-1', - model: 'k2', - permissionMode: 'yolo', - planMode: true, - }); - }); - - it('does not force manual permission after OAuth login without --yolo', async () => { - const session = makeSession({ - getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingLevel: 'off', - permission: 'auto', - planMode: false, - contextTokens: 10, - maxContextTokens: 100, - contextUsage: 0.1, - })), - }); - const createSession = vi - .fn() - .mockRejectedValueOnce(loginRequiredError()) - .mockResolvedValueOnce(session); - const harness = makeHarness(session, { - getConfig: vi.fn(async () => ({ - defaultModel: 'k2', - defaultThinking: false, - models: { - k2: { model: 'pythoughts-v1', maxContextSize: 100 }, - }, - })), - createSession, - }); - const driver = makeDriver(harness, makeStartupInput()); - await expect(driver.init()).resolves.toBe(false); - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'kimi-code', - catalog: {}, - }); - await handleLoginCommand(driver as any); - expect(createSession).toHaveBeenNthCalledWith(2, { - workDir: '/tmp/proj-a', - model: 'k2', - thinking: 'off', - permission: undefined, - planMode: undefined, - }); - expect(driver.state.appState).toMatchObject({ - permissionMode: 'auto', - }); - }); - - it('syncs configured thinking after OAuth login refreshes an active session', async () => { - const session = makeSession(); - const harness = makeHarness(session, { - getConfig: vi.fn(async () => ({ - defaultModel: 'k2', - defaultThinking: true, - models: { - k2: { model: 'pythoughts-v1', maxContextSize: 100, capabilities: ['thinking'] }, - }, - })), - }); - const driver = makeDriver(harness, makeStartupInput()); - - await expect(driver.init()).resolves.toBe(false); - expect(driver.state.appState.thinkingLevel).toBe('off'); - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'kimi-code', - catalog: {}, - }); - await handleLoginCommand(driver as any); - expect(session.setModel).toHaveBeenCalledWith('k2'); - expect(session.setThinking).toHaveBeenCalledWith('high'); - expect(driver.state.appState).toMatchObject({ - model: 'k2', - thinkingLevel: 'high', - maxContextTokens: 100, - }); - expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'managed:kimi-code', - already_logged_in: false, - }); - }); - - it('tracks login with already_logged_in when a token already exists', async () => { - const session = makeSession(); - const harness = makeHarness(session, { - auth: { - status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:kimi-code', hasToken: true }], - })), - login: vi.fn(async () => {}), - logout: vi.fn(), - getManagedUsage: vi.fn(), - }, - }); - const driver = makeDriver(harness, makeStartupInput()); - - await expect(driver.init()).resolves.toBe(false); - harness.track.mockClear(); - - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'kimi-code', - catalog: {}, - }); - await handleLoginCommand(driver as any); - - expect(harness.auth.login).toHaveBeenCalledWith( - 'managed:kimi-code', - expect.objectContaining({ - signal: expect.any(AbortSignal), - onDeviceCode: expect.any(Function), - }), - ); - expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'managed:kimi-code', - already_logged_in: true, - }); - }); - - it('logs login failures with session context', async () => { - const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); - const session = makeSession(); - const loginError = new Error('Failed to list Pythinker Code models (HTTP 402).'); - const harness = makeHarness(session, { - auth: { - status: vi.fn(async () => ({ providers: [] })), - login: vi.fn(async () => { - throw loginError; - }), - logout: vi.fn(), - getManagedUsage: vi.fn(), - }, - }); - const driver = makeDriver(harness, makeStartupInput()); - - try { - await expect(driver.init()).resolves.toBe(false); - - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: 'kimi-code', - catalog: {}, - }); - await handleLoginCommand(driver as any); - - expect(harness.auth.login).toHaveBeenCalledWith( - 'managed:kimi-code', - expect.objectContaining({ - signal: expect.any(AbortSignal), - onDeviceCode: expect.any(Function), - }), - ); - expect(warn).toHaveBeenCalledWith( - 'login failed', - expect.objectContaining({ - providerName: 'managed:kimi-code', - alreadyLoggedIn: false, - sessionId: 'ses-1', - error: expect.objectContaining({ - message: 'Failed to list Pythinker Code models (HTTP 402).', - }), - }), - ); - } finally { - warn.mockRestore(); - } - }); it('connects a catalog provider with an environment API key', async () => { const setConfig = vi.fn(async (patch: unknown) => patch); @@ -1882,41 +1658,6 @@ describe('PythinkerTUI startup', () => { } }); - it('tracks logout after managed credentials and session state are cleared', async () => { - const session = makeSession(); - const harness = makeHarness(session, { - getConfig: vi.fn(async () => ({ - models: { - k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, - }, - providers: { 'managed:kimi-code': { type: 'pythinker' } }, - })), - auth: { - status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:kimi-code', hasToken: true }], - })), - login: vi.fn(async () => {}), - logout: vi.fn(), - getManagedUsage: vi.fn(), - }, - }); - const driver = makeDriver(harness, makeStartupInput()); - - await expect(driver.init()).resolves.toBe(false); - harness.track.mockClear(); - - vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); - await handleLogoutCommand(driver as any); - - expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); - expect(session.close).toHaveBeenCalledOnce(); - expect(driver.state.appState).toMatchObject({ - sessionId: '', - model: '', - sessionTitle: null, - }); - expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:kimi-code' }); - }); it('keeps the active session when logging out a different provider', async () => { const session = makeSession(); @@ -1959,35 +1700,6 @@ describe('PythinkerTUI startup', () => { expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'openai' }); }); - it('can log out a stale managed entry even after the OAuth token is gone', async () => { - const session = makeSession(); - const harness = makeHarness(session, { - getConfig: vi.fn(async () => ({ - models: { - k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, - }, - providers: { 'managed:kimi-code': { type: 'pythinker' } }, - })), - auth: { - // Token gone (e.g. credentials file deleted) but the managed entry - // is still sitting in config.providers. - status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:kimi-code', hasToken: false }], - })), - login: vi.fn(async () => {}), - logout: vi.fn(), - getManagedUsage: vi.fn(), - }, - }); - const driver = makeDriver(harness, makeStartupInput()); - - await expect(driver.init()).resolves.toBe(false); - - vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:kimi-code'); - await handleLogoutCommand(driver as any); - - expect(harness.auth.logout).toHaveBeenCalledWith('managed:kimi-code'); - }); it('starts TUI without replaying when --continue needs OAuth login', async () => { const harness = makeHarness(makeSession(), { diff --git a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts index d57c7ef1..1f6b5632 100644 --- a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts @@ -2,11 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { - KIMI_CODE_PROVIDER_NAME, - resolveKimiCodeOAuthKey, - resolveKimiCodeOAuthRef, -} from '@pythoughts/pythinker-code-oauth'; + import { afterEach, describe, expect, it, vi } from 'vitest'; import { refreshAllProviderModels } from '../../../src/tui/utils/refresh-providers'; @@ -69,131 +65,44 @@ describe('refreshAllProviderModels', () => { vi.unstubAllGlobals(); }); - it('refreshes managed Pythinker Code against environment endpoints over persisted config', async () => { - const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; - const envBaseUrl = 'https://api.env.example.test/coding/v1'; - const envOauthHost = 'https://auth.env.example.test'; - const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl }); - const envOauthRef = resolveKimiCodeOAuthRef({ - oauthHost: envOauthHost, - baseUrl: envBaseUrl, - }); - const config: PythinkerConfig = { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - baseUrl: configuredBaseUrl, - apiKey: '', - oauth: { - storage: 'file', - key: configuredOauthKey, - oauthHost: 'https://auth.pythinker.com', - }, - }, - }, - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 262144, - capabilities: ['thinking', 'tool_use'], - }, - }, - defaultModel: 'kimi-code/pythinker-for-coding', - telemetry: true, - }; - vi.stubEnv('PYTHINKER_CODE_BASE_URL', envBaseUrl); - vi.stubEnv('PYTHINKER_CODE_OAUTH_HOST', envOauthHost); - const resolveOAuthToken = vi.fn(async (_providerName, oauthRef) => { - expect(oauthRef).toEqual(envOauthRef); - return 'env-access-token'; - }); - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(`${envBaseUrl}/models`); - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer env-access-token'); - return new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchMock); - - const result = await refreshAllProviderModels({ - getConfig: async () => config, - removeProvider: vi.fn(), - setConfig: vi.fn(), - replaceConfig: vi.fn(), - resolveOAuthToken, - }); - expect(result.failed).toEqual([]); - expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(resolveOAuthToken).toHaveBeenCalledWith(KIMI_CODE_PROVIDER_NAME, envOauthRef); - }); - it('can refresh only the managed OAuth provider without fetching third-party registries', async () => { - const baseUrl = 'https://api.example.test/coding/v1'; - const registryUrl = 'https://registry.example.test/v1/models/api.json'; - const config: PythinkerConfig = { + it('refreshes the OpenAI Codex provider under scope oauth and drops a default model the refresh removed', async () => { + const host = makeRefreshHost({ providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - baseUrl, - apiKey: '', - oauth: { - storage: 'file', - key: resolveKimiCodeOAuthKey({ baseUrl }), - }, - }, - custom: { - type: 'openai', - baseUrl: 'https://custom.example.test/v1', - apiKey: 'sk-test-token', - source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + 'openai-codex': { + type: 'openai_responses', + baseUrl: 'https://chatgpt.com/backend-api/codex', + apiKey: 'codex-access-token', + customHeaders: { 'chatgpt-account-id': 'acct-1' }, + source: { auth: 'openai-codex-oauth', accountId: 'acct-1', refreshToken: 'codex-refresh' }, }, + manual: { type: 'openai', baseUrl: 'https://manual.example.test/v1', apiKey: 'sk-manual' }, }, models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 262144, - capabilities: ['thinking', 'tool_use'], - displayName: 'Old Pythinker', + 'openai-codex/gone': { + provider: 'openai-codex', + model: 'gone', + maxContextSize: 128_000, + capabilities: ['tool_use'], }, - 'custom/m1': { - provider: 'custom', - model: 'm1', - maxContextSize: 131072, + 'manual/kept': { + provider: 'manual', + model: 'kept', + maxContextSize: 8_000, capabilities: ['tool_use'], - displayName: 'Custom M1', }, }, - defaultModel: 'kimi-code/pythinker-for-coding', - telemetry: true, - }; - const host = makeRefreshHost(config); - const resolveOAuthToken = vi.fn(async () => 'oauth-access-token'); - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer oauth-access-token'); + defaultModel: 'openai-codex/gone', + defaultThinking: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn(async (input) => { + expect(fetchInputUrl(input)).toContain('/models?client_version='); return new Response( JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - display_name: 'Fresh Pythinker', - }, + models: [ + { id: 'gpt-5-codex', context_length: 272_000, supports_reasoning: true }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -207,24 +116,22 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken, }, { scope: 'oauth' }, ); expect(result.failed).toEqual([]); expect(result.changed).toEqual([ - { - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: 'Pythinker', - added: 0, - removed: 0, - }, + { providerId: 'openai-codex', providerName: 'OpenAI Codex (OAuth)', added: 1, removed: 1 }, ]); - expect(result.unchanged).toEqual([]); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(host.current().models?.['kimi-code/pythinker-for-coding']?.displayName).toBe('Fresh Pythinker'); - expect(host.current().models?.['custom/m1']?.displayName).toBe('Custom M1'); + // scope 'oauth' must not touch the hand-written provider. + expect(host.current().providers['manual']).toMatchObject({ apiKey: 'sk-manual' }); + expect(host.current().models?.['manual/kept']).toBeDefined(); + // The old default alias is gone. The refresh re-points the selection at the + // model it just fetched rather than clearing it, so the session still has a + // model to run on. + expect(host.current().models?.['openai-codex/gone']).toBeUndefined(); + expect(host.current().defaultModel).toBe('openai-codex/gpt-5-codex'); }); it('refreshes catalog-backed providers once per models.dev source', async () => { @@ -309,7 +216,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -376,7 +282,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -432,7 +337,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([ @@ -485,7 +389,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.changed).toHaveLength(1); @@ -592,7 +495,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -684,7 +586,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -788,7 +689,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -889,7 +789,6 @@ max_context_size = 64000 removeProvider: (providerId) => harness.removeProvider(providerId), setConfig: (patch) => harness.setConfig(patch), replaceConfig: (config) => harness.replaceConfig(config), - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); expect(result.changed).toContainEqual({ @@ -994,7 +893,6 @@ max_context_size = 64000 removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -1094,7 +992,6 @@ max_context_size = 64000 removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -1108,61 +1005,4 @@ max_context_size = 64000 expect(host.current().defaultThinking).toBe(false); }); - it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { - const host = makeRefreshHost({ - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - }, - models: { - 'kimi-code/pythinker-deep-coder': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-deep-coder', - maxContextSize: 262144, - capabilities: ['thinking', 'tool_use'], - }, - }, - defaultModel: 'kimi-code/pythinker-deep-coder', - defaultThinking: false, - telemetry: true, - } as unknown as PythinkerConfig); - - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-deep-coder', - context_length: 262144, - supports_reasoning: true, - supports_thinking_type: 'only', - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - vi.stubGlobal('fetch', fetchMock); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(async () => 'oauth-access-token'), - }); - - expect(result.failed).toEqual([]); - expect(host.current().models?.['kimi-code/pythinker-deep-coder']?.capabilities).toEqual([ - 'thinking', - 'always_thinking', - 'tool_use', - ]); - expect(host.current().defaultModel).toBe('kimi-code/pythinker-deep-coder'); - expect(host.current().defaultThinking).toBe(true); - }); }); diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index d3711e12..8e274684 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -13,7 +13,6 @@ import DiffView from './components/DiffView.vue'; import type { AgentMember } from './types'; import ModelPicker from './components/ModelPicker.vue'; import ProviderManager from './components/ProviderManager.vue'; -import LoginDialog from './components/LoginDialog.vue'; import NewSessionDialog from './components/NewSessionDialog.vue'; import SettingsDialog from './components/SettingsDialog.vue'; import SessionsDialog from './components/SessionsDialog.vue'; @@ -580,7 +579,6 @@ function handleSelectWorkspaces(ids: string[]): void { // Dialog visibility refs const showModelPicker = ref(false); const showProviders = ref(false); -const showLogin = ref(false); const showNewSession = ref(false); const showSessions = ref(false); const showAddWorkspace = ref(false); @@ -600,7 +598,6 @@ const pendingWorkspaceSubmit = ref(null); const anyOverlayOpen = computed(() => showModelPicker.value || showProviders.value || - showLogin.value || showNewSession.value || showSessions.value || showAddWorkspace.value || @@ -645,8 +642,10 @@ async function openProviders(): Promise { } } +// Logging in is provider configuration now: every path (API key, models.dev +// catalog entry) is added through the provider manager. function openLogin(): void { - showLogin.value = true; + void openProviders(); } async function handleSelectModel(modelId: string): Promise { @@ -678,25 +677,6 @@ async function handleUpdateConfig(patch: Partial): Promise { } } -// LoginDialog callbacks — delegates to composable -async function handleStartOAuthLogin() { - return client.startOAuthLogin(); -} - -async function handlePollOAuthLogin() { - return client.pollOAuthLogin(); -} - -async function handleCancelOAuthLogin() { - return client.cancelOAuthLogin(); -} - -async function handleLoginSuccess(): Promise { - showLogin.value = false; - // Re-check auth state and reload sessions now that we're authenticated - await client.checkAuth(); - await client.load(); -} // Edit + resend the last user message: undo the latest exchange on the daemon, // then drop that message's text back into the composer for editing. @@ -1155,8 +1135,7 @@ function openPr(url: string): void { @set-beta-toc="client.setBetaToc($event)" @update-config="handleUpdateConfig($event)" @login="() => { showSettings = false; openLogin(); }" - @logout="client.logout" - @open-onboarding="() => { showSettings = false; openOnboarding(); }" + @open-onboarding="() => { showSettings = false; openOnboarding(); }" @close="showSettings = false" /> @@ -1169,7 +1148,6 @@ function openPr(url: string): void { @add="handleAddProvider($event)" @refresh="handleRefreshProvider($event)" @delete="handleDeleteProvider($event)" - @open-login="() => { showProviders = false; openLogin(); }" @close="showProviders = false" /> @@ -1275,18 +1253,8 @@ function openPr(url: string): void { @set-ui-font-size="client.setUiFontSize($event)" @set-beta-toc="client.setBetaToc($event)" @login="() => { showMobileSettings = false; openLogin(); }" - @logout="client.logout" - /> + /> - - diff --git a/apps/pythinker-web/src/api/daemon/client.ts b/apps/pythinker-web/src/api/daemon/client.ts index 8c02d96e..173ba558 100644 --- a/apps/pythinker-web/src/api/daemon/client.ts +++ b/apps/pythinker-web/src/api/daemon/client.ts @@ -64,9 +64,6 @@ import type { WireFsHomeResult, WireMessage, WireModel, - WireOAuthCancelResult, - WireOAuthLoginPollResult, - WireOAuthLoginStartResult, WirePage, WirePromptSubmitResult, WirePromptSteerResult, @@ -77,7 +74,6 @@ import type { WireSessionRuntimeStatus, WireSessionSnapshot, WireWorkspace, - WireLogoutResult, } from './wire'; import { DaemonEventSocket } from './ws'; @@ -1123,68 +1119,18 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi { ready: boolean; providersCount: number; defaultModel: string | null; - managedProvider: { status: string } | null; }> { const data = await this.http.get('/auth'); return { ready: data.ready, providersCount: data.providers_count, defaultModel: data.default_model, - managedProvider: data.managed_provider - ? { status: data.managed_provider.status } - : null, }; } - async startOAuthLogin(): Promise<{ - flowId: string; - provider: string; - verificationUri: string; - verificationUriComplete: string; - userCode: string; - expiresIn: number; - interval: number; - status: 'pending'; - expiresAt: string; - }> { - const data = await this.http.post('/oauth/login', {}); - return { - flowId: data.flow_id, - provider: data.provider, - verificationUri: data.verification_uri, - verificationUriComplete: data.verification_uri_complete, - userCode: data.user_code, - expiresIn: data.expires_in, - interval: data.interval, - status: data.status, - expiresAt: data.expires_at, - }; - } - async pollOAuthLogin(): Promise<{ - flowId: string; - status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; - resolvedAt?: string; - } | null> { - // data may be null if no flow is active - const data = await this.http.get('/oauth/login'); - if (!data) return null; - return { - flowId: data.flow_id, - status: data.status, - resolvedAt: data.resolved_at, - }; - } - async cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }> { - const data = await this.http.delete('/oauth/login'); - return { cancelled: data.cancelled, status: data.status }; - } - async logout(): Promise<{ loggedOut: boolean }> { - const data = await this.http.post('/oauth/logout', {}); - return { loggedOut: data.logged_out }; - } // ------------------------------------------------------------------------- // File upload diff --git a/apps/pythinker-web/src/api/daemon/wire.ts b/apps/pythinker-web/src/api/daemon/wire.ts index db482ee4..7d6aef1a 100644 --- a/apps/pythinker-web/src/api/daemon/wire.ts +++ b/apps/pythinker-web/src/api/daemon/wire.ts @@ -387,47 +387,19 @@ export interface WireConfig { } // --------------------------------------------------------------------------- -// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout) +// Auth wire DTOs — REAL endpoint (GET /api/v1/auth) // --------------------------------------------------------------------------- -export interface WireManagedProvider { - status: string; - [key: string]: unknown; -} export interface WireAuthResult { ready: boolean; providers_count: number; default_model: string | null; - managed_provider: WireManagedProvider | null; } -export interface WireOAuthLoginStartResult { - flow_id: string; - provider: string; - verification_uri: string; - verification_uri_complete: string; - user_code: string; - expires_in: number; - interval: number; - status: 'pending'; - expires_at: string; -} -export interface WireOAuthLoginPollResult { - flow_id: string; - status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; - resolved_at?: string; -} -export interface WireOAuthCancelResult { - cancelled: boolean; - status: string; -} -export interface WireLogoutResult { - logged_out: boolean; -} // --------------------------------------------------------------------------- // File upload wire DTOs diff --git a/apps/pythinker-web/src/api/types.ts b/apps/pythinker-web/src/api/types.ts index da7349b6..b3ba5821 100644 --- a/apps/pythinker-web/src/api/types.ts +++ b/apps/pythinker-web/src/api/types.ts @@ -680,24 +680,5 @@ export interface PythinkerWebApi { ready: boolean; providersCount: number; defaultModel: string | null; - managedProvider: { status: string } | null; }>; - startOAuthLogin(): Promise<{ - flowId: string; - provider: string; - verificationUri: string; - verificationUriComplete: string; - userCode: string; - expiresIn: number; - interval: number; - status: 'pending'; - expiresAt: string; - }>; - pollOAuthLogin(): Promise<{ - flowId: string; - status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; - resolvedAt?: string; - } | null>; - cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>; - logout(): Promise<{ loggedOut: boolean }>; } diff --git a/apps/pythinker-web/src/components/LoginDialog.vue b/apps/pythinker-web/src/components/LoginDialog.vue deleted file mode 100644 index 57abc525..00000000 --- a/apps/pythinker-web/src/components/LoginDialog.vue +++ /dev/null @@ -1,621 +0,0 @@ - - - - - - - - diff --git a/apps/pythinker-web/src/components/MobileSettingsSheet.vue b/apps/pythinker-web/src/components/MobileSettingsSheet.vue index bc75702e..592f6696 100644 --- a/apps/pythinker-web/src/components/MobileSettingsSheet.vue +++ b/apps/pythinker-web/src/components/MobileSettingsSheet.vue @@ -44,7 +44,6 @@ const emit = defineEmits<{ setUiFontSize: [size: number]; setBetaToc: [on: boolean]; login: []; - logout: []; }>(); // Tap-to-cycle order follows the same safest → most permissive progression @@ -100,10 +99,6 @@ function onLogin(): void { emit('update:modelValue', false); } -function onLogout(): void { - emit('logout'); - emit('update:modelValue', false); -}