From 7276b30eb1df58d9e35306b840324f2249e609f4 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 01:24:29 -0400 Subject: [PATCH 1/8] refactor(oauth): make the login platform layer provider-neutral Model listing, capability derivation and the on-disk config shape lived in a provider-specific module while every other login path imported them from there. Move them into open-platform.ts as PlatformModelInfo / PlatformConfigShape and collapse the two duplicate copies of capabilitiesForModel and toModelInfo into one; the survivor parses supported_reasoning_efforts so no field is lost. Drop the managed-subscription provider along with it: the device-code OAuth flow, its token storage and manager, the managed usage and feedback endpoints, the agent-core OAuth service and REST routes, the SDK auth facade, and the managed model provider. Nothing configured them any more once model listing became provider-neutral. Logging in is now: API key, models.dev catalog provider, or OpenAI Codex OAuth. 'Is the user logged in' becomes PythinkerHarness.isAuthenticated(), one predicate over configured providers with a usable credential, shared by the VS Code extension and the ACP adapter. /feedback opens the issue tracker. --- .../src/auth/terminal-login-ui.ts | 22 +- apps/pythinker-code/src/cli/run-prompt.ts | 7 - apps/pythinker-code/src/cli/run-shell.ts | 10 - apps/pythinker-code/src/cli/sub/provider.ts | 6 +- apps/pythinker-code/src/cli/telemetry.ts | 12 +- apps/pythinker-code/src/constant/app.ts | 3 - apps/pythinker-code/src/tui/commands/auth.ts | 30 +- .../src/tui/commands/dispatch.ts | 2 - apps/pythinker-code/src/tui/commands/info.ts | 80 +- .../src/tui/commands/prompts.ts | 6 +- .../src/tui/commands/provider.ts | 12 +- .../tui/components/dialogs/model-selector.ts | 2 - .../components/dialogs/provider-manager.ts | 3 - .../src/tui/constant/pythinker-tui.ts | 9 +- .../src/tui/controllers/auth-flow.ts | 4 - apps/pythinker-code/src/tui/pythinker-tui.ts | 15 - .../src/tui/utils/refresh-providers.ts | 89 +- apps/pythinker-code/test/cli/acp.test.ts | 7 +- apps/pythinker-code/test/cli/export.test.ts | 1 - apps/pythinker-code/test/cli/login.test.ts | 125 +- .../pythinker-code/test/cli/run-shell.test.ts | 45 - .../components/dialogs/model-selector.test.ts | 6 +- .../dialogs/tabbed-model-selector.test.ts | 10 +- .../tui/pythinker-tui-message-flow.test.ts | 66 +- .../test/tui/pythinker-tui-startup.test.ts | 288 ----- .../test/tui/utils/refresh-providers.test.ts | 229 +--- apps/vscode/src/auth/vscode-login-ui.ts | 7 - apps/vscode/src/handlers/auth.handler.ts | 5 +- apps/vscode/src/utils/context.ts | 3 +- packages/acp-adapter/src/server.ts | 9 +- packages/agent-core/src/rpc/core-impl.ts | 61 +- .../src/services/auth/managedAuth.ts | 174 --- .../authSummary/authSummaryService.ts | 67 +- .../coreProcess/coreProcessService.ts | 35 - packages/agent-core/src/services/index.ts | 2 - .../src/services/modelCatalog/modelCatalog.ts | 2 - .../modelCatalog/modelCatalogService.ts | 247 +--- .../agent-core/src/services/oauth/oauth.ts | 107 -- .../src/services/oauth/oauthService.ts | 310 ----- .../agent-core/test/harness/runtime.test.ts | 64 - .../agent-core/test/rpc/plugins-rpc.test.ts | 57 - .../services/auth-summary-service.test.ts | 7 +- .../test/services/coreProcessService.test.ts | 7 - .../services/model-catalog-service.test.ts | 86 +- .../test/services/oauth-service.test.ts | 338 ----- .../examples/pythinker-harness-auth-smoke.ts | 119 -- packages/node-sdk/src/auth.ts | 230 ---- packages/node-sdk/src/index.ts | 8 - packages/node-sdk/src/login/flows.ts | 80 +- packages/node-sdk/src/login/model-alias.ts | 4 +- packages/node-sdk/src/login/types.ts | 8 +- packages/node-sdk/src/mcp-server.ts | 1 - packages/node-sdk/src/oauth-error.ts | 41 - .../src/pythinker-code-model-provider.ts | 138 -- packages/node-sdk/src/pythinker-harness.ts | 18 +- packages/node-sdk/src/sdk-rpc-client.ts | 15 - packages/node-sdk/src/types.ts | 4 +- packages/node-sdk/test/auth-facade.test.ts | 876 ------------- .../test/create-session-transport.test.ts | 1 - .../pythinker-code-model-provider.test.ts | 76 -- .../test/runtime-provider-oauth.test.ts | 229 ---- .../node-sdk/test/session-set-model.test.ts | 67 - packages/oauth/examples/kimi-oauth-smoke.ts | 128 -- packages/oauth/src/constants.ts | 12 - packages/oauth/src/custom-registry.ts | 10 +- packages/oauth/src/errors.ts | 24 - packages/oauth/src/identity.ts | 12 +- packages/oauth/src/index.ts | 107 +- packages/oauth/src/managed-feedback.ts | 77 -- packages/oauth/src/managed-kimi-code.ts | 739 ----------- packages/oauth/src/managed-usage.ts | 237 ---- packages/oauth/src/oauth-manager.ts | 480 ------- packages/oauth/src/oauth.ts | 311 ----- packages/oauth/src/open-platform.ts | 103 +- packages/oauth/src/openai-codex-oauth.ts | 26 +- packages/oauth/src/storage.ts | 138 -- packages/oauth/src/token-state.ts | 45 - packages/oauth/src/toolkit.ts | 408 ------ packages/oauth/src/types.ts | 87 -- packages/oauth/test/custom-registry.test.ts | 24 +- packages/oauth/test/managed-feedback.test.ts | 177 --- packages/oauth/test/managed-kimi-code.test.ts | 1120 ----------------- packages/oauth/test/managed-usage.test.ts | 184 --- .../test/oauth-manager-lock-failure.test.ts | 95 -- .../test/oauth-manager-multi-process.test.ts | 268 ---- packages/oauth/test/oauth-manager.test.ts | 903 ------------- packages/oauth/test/oauth.test.ts | 729 ----------- packages/oauth/test/open-platform.test.ts | 19 +- .../oauth/test/openai-codex-oauth.test.ts | 6 +- packages/oauth/test/refresh-threshold.test.ts | 30 - packages/oauth/test/storage.test.ts | 214 ---- packages/oauth/test/toolkit.test.ts | 591 --------- .../protocol/src/__tests__/rest-auth.test.ts | 66 +- packages/protocol/src/index.ts | 1 - packages/protocol/src/rest/auth.ts | 22 +- packages/protocol/src/rest/modelCatalog.ts | 10 - packages/protocol/src/rest/oauth.ts | 73 -- packages/server/src/routes/modelCatalog.ts | 22 - packages/server/src/routes/oauth.ts | 172 --- .../server/src/routes/registerApiV1Routes.ts | 2 - packages/server/src/start.ts | 3 +- packages/server/test/auth.e2e.test.ts | 24 - .../server/test/model-catalog.e2e.test.ts | 12 - packages/server/test/oauth.e2e.test.ts | 333 ----- 104 files changed, 259 insertions(+), 12377 deletions(-) delete mode 100644 packages/agent-core/src/services/auth/managedAuth.ts delete mode 100644 packages/agent-core/src/services/oauth/oauth.ts delete mode 100644 packages/agent-core/src/services/oauth/oauthService.ts delete mode 100644 packages/agent-core/test/services/oauth-service.test.ts delete mode 100644 packages/node-sdk/examples/pythinker-harness-auth-smoke.ts delete mode 100644 packages/node-sdk/src/auth.ts delete mode 100644 packages/node-sdk/src/oauth-error.ts delete mode 100644 packages/node-sdk/src/pythinker-code-model-provider.ts delete mode 100644 packages/node-sdk/test/auth-facade.test.ts delete mode 100644 packages/node-sdk/test/pythinker-code-model-provider.test.ts delete mode 100644 packages/node-sdk/test/runtime-provider-oauth.test.ts delete mode 100644 packages/oauth/examples/kimi-oauth-smoke.ts delete mode 100644 packages/oauth/src/constants.ts delete mode 100644 packages/oauth/src/managed-feedback.ts delete mode 100644 packages/oauth/src/managed-kimi-code.ts delete mode 100644 packages/oauth/src/managed-usage.ts delete mode 100644 packages/oauth/src/oauth-manager.ts delete mode 100644 packages/oauth/src/oauth.ts delete mode 100644 packages/oauth/src/storage.ts delete mode 100644 packages/oauth/src/token-state.ts delete mode 100644 packages/oauth/src/toolkit.ts delete mode 100644 packages/oauth/src/types.ts delete mode 100644 packages/oauth/test/managed-feedback.test.ts delete mode 100644 packages/oauth/test/managed-kimi-code.test.ts delete mode 100644 packages/oauth/test/managed-usage.test.ts delete mode 100644 packages/oauth/test/oauth-manager-lock-failure.test.ts delete mode 100644 packages/oauth/test/oauth-manager-multi-process.test.ts delete mode 100644 packages/oauth/test/oauth-manager.test.ts delete mode 100644 packages/oauth/test/oauth.test.ts delete mode 100644 packages/oauth/test/refresh-threshold.test.ts delete mode 100644 packages/oauth/test/storage.test.ts delete mode 100644 packages/oauth/test/toolkit.test.ts delete mode 100644 packages/protocol/src/rest/oauth.ts delete mode 100644 packages/server/src/routes/oauth.ts delete mode 100644 packages/server/test/oauth.e2e.test.ts 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..51ff55ef 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 { 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..536dc5c9 100644 --- a/apps/pythinker-code/test/cli/export.test.ts +++ b/apps/pythinker-code/test/cli/export.test.ts @@ -411,7 +411,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..4dc6074d 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,25 @@ 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); + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select a provider' }), + ); expect(exitSpy.mock.calls[0]?.[0]).toBe(0); }); @@ -378,16 +283,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/run-shell.test.ts b/apps/pythinker-code/test/cli/run-shell.test.ts index 2b6aa6e9..5ec253e4 100644 --- a/apps/pythinker-code/test/cli/run-shell.test.ts +++ b/apps/pythinker-code/test/cli/run-shell.test.ts @@ -234,7 +234,6 @@ describe('runShell', () => { version: '1.2.3-test', uiMode: 'shell', model: 'k2', - getAccessToken: expect.any(Function), }); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); @@ -436,50 +435,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..1a62c6bf 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/); + 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/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..a9701426 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,163 +65,7 @@ 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 = { - 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' }, - }, - }, - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 262144, - capabilities: ['thinking', 'tool_use'], - displayName: 'Old Pythinker', - }, - 'custom/m1': { - provider: 'custom', - model: 'm1', - maxContextSize: 131072, - 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'); - return new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - display_name: 'Fresh Pythinker', - }, - ], - }), - { 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, - }, - { scope: 'oauth' }, - ); - expect(result.failed).toEqual([]); - expect(result.changed).toEqual([ - { - providerId: KIMI_CODE_PROVIDER_NAME, - providerName: 'Pythinker', - added: 0, - removed: 0, - }, - ]); - 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'); - }); it('refreshes catalog-backed providers once per models.dev source', async () => { const catalogUrl = 'https://catalog.example.test/api.json'; @@ -309,7 +149,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -376,7 +215,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -432,7 +270,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([ @@ -485,7 +322,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.changed).toHaveLength(1); @@ -592,7 +428,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -684,7 +519,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -788,7 +622,6 @@ describe('refreshAllProviderModels', () => { removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -889,7 +722,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 +826,6 @@ max_context_size = 64000 removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -1094,7 +925,6 @@ max_context_size = 64000 removeProvider: host.removeProvider, setConfig: host.setConfig, replaceConfig: host.replaceConfig, - resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -1108,61 +938,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/vscode/src/auth/vscode-login-ui.ts b/apps/vscode/src/auth/vscode-login-ui.ts index 292ed1df..f206efa6 100644 --- a/apps/vscode/src/auth/vscode-login-ui.ts +++ b/apps/vscode/src/auth/vscode-login-ui.ts @@ -34,7 +34,6 @@ import { type LoginUi, } from "@pythoughts/pythinker-code-sdk"; -import { Events } from "../../shared/bridge"; import type { HandlerContext } from "../handlers/types"; // Filled by the tsdown define in release builds (same env var the CLI's @@ -140,12 +139,6 @@ export function createVscodeLoginUi( showStatus, showError, showLoginProgressSpinner, - showLoginAuthorizationPrompt(auth) { - const url = auth.verificationUriComplete || auth.verificationUri; - ctx.broadcast(Events.LoginUrl, { url }, ctx.webviewId); - openBrowser(url); - return createProgressHandle(`Waiting for authorization — enter code ${auth.userCode}`); - }, async promptPlatformSelection() { let catalog = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON) ?? {}; const controller = new AbortController(); diff --git a/apps/vscode/src/handlers/auth.handler.ts b/apps/vscode/src/handlers/auth.handler.ts index 138de03e..159d4a55 100644 --- a/apps/vscode/src/handlers/auth.handler.ts +++ b/apps/vscode/src/handlers/auth.handler.ts @@ -81,7 +81,10 @@ export const authHandlers: Record> = { [Methods.Logout]: async (_, ctx): Promise => { try { - await ctx.harness.auth.logout(); + const config = await ctx.harness.getConfig({ reload: true }); + for (const providerId of Object.keys(config.providers ?? {})) { + await ctx.harness.removeProvider(providerId); + } await updateLoginContext(ctx.harness); return { success: true }; } catch (error) { diff --git a/apps/vscode/src/utils/context.ts b/apps/vscode/src/utils/context.ts index 4f14a0ed..27a30100 100644 --- a/apps/vscode/src/utils/context.ts +++ b/apps/vscode/src/utils/context.ts @@ -2,8 +2,7 @@ import * as vscode from "vscode"; import type { PythinkerHarness } from "@pythoughts/pythinker-code-sdk"; export async function updateLoginContext(harness: PythinkerHarness): Promise { - const status = await harness.auth.status(); - const loggedIn = status.providers.some((provider) => provider.hasToken); + const loggedIn = await harness.isAuthenticated(); await vscode.commands.executeCommand("setContext", "pythinker.isLoggedIn", loggedIn); return loggedIn; } diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index bd32b215..c7bd6b26 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -104,15 +104,8 @@ function toResolvedSlashCommands( }; } -/** - * Inline auth gate — moved out of `PythinkerAuthFacade.hasUsableToken()` so - * the SDK doesn't have to carry an ACP-specific convenience method. - * Mirrors the original semantics exactly: any provider with `hasToken` - * set counts as authed. - */ async function harnessIsAuthed(harness: PythinkerHarness): Promise { - const status = await harness.auth.status(); - return status.providers.some((entry) => entry.hasToken === true); + return harness.isAuthenticated(); } /** diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 82942559..ebbd6037 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -1,4 +1,3 @@ -import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -29,7 +28,6 @@ import { resolvePythinkerHome, writeConfigFile, type PythinkerConfig, - type McpServerConfig, type PythoughtsServiceConfig, } from '../config'; import { FLAG_DEFINITIONS, FlagResolver, type ExperimentalFeatureState } from '../flags'; @@ -49,8 +47,6 @@ import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; import { exportSessionDirectory } from '../session/export'; import { ProviderManager, - type BearerTokenProvider, - type OAuthTokenProviderResolver, } from '../session/provider-manager'; import { SessionAPIImpl } from '../session/rpc'; import { normalizeWorkDir, SessionStore } from '../session/store/index'; @@ -135,9 +131,6 @@ import type { ResumedAgentState, ResumeSessionResult } from './resumed'; import type { SDKRPC } from './sdk-api'; import { proxyWithExtraPayload } from './types'; -const PYTHINKER_CODE_BASE_URL_ENV = 'PYTHINKER_CODE_BASE_URL'; -const PYTHINKER_CODE_OAUTH_HOST_ENV = 'PYTHINKER_CODE_OAUTH_HOST'; -const PYTHINKER_OAUTH_HOST_ENV = 'PYTHINKER_OAUTH_HOST'; type AgentScopedPayload = T & { readonly agentId: string }; type SessionScopedPayload = T & { readonly sessionId: string }; type SessionAgentPayload = SessionScopedPayload>; @@ -149,7 +142,6 @@ export interface PythinkerCoreOptions { readonly configPath?: string | undefined; readonly runtime?: ToolServices | undefined; readonly pythinkerRequestHeaders?: Record | undefined; - readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; readonly resolveWorkspaceId?: (workDir: string) => Promise; readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; @@ -170,7 +162,6 @@ export class PythinkerCore implements PromisableMethods { private readonly runtimeOverride: ToolServices | undefined; private readonly userHomeDir: string; private readonly pythinkerRequestHeaders: Record | undefined; - private readonly resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined; private readonly skillDirs: readonly string[]; private readonly sessionStore: SessionStore; readonly plugins: PluginManager; @@ -195,7 +186,6 @@ export class PythinkerCore implements PromisableMethods { this.runtimeOverride = options.runtime; this.runtime = options.runtime; this.pythinkerRequestHeaders = options.pythinkerRequestHeaders; - this.resolveOAuthTokenProvider = options.resolveOAuthTokenProvider; this.skillDirs = options.skillDirs ?? []; this.telemetry = options.telemetry ?? noopTelemetryClient; this.appVersion = options.appVersion; @@ -1021,7 +1011,6 @@ export class PythinkerCore implements PromisableMethods { const providers = await createRuntimeConfig({ config, pythinkerRequestHeaders: this.pythinkerRequestHeaders, - resolveOAuthTokenProvider: this.resolveOAuthTokenProvider, }); const runtime = this.withConfigStore(providers); this.runtime = runtime; @@ -1125,13 +1114,12 @@ export class PythinkerCore implements PromisableMethods { return new ProviderManager({ config: () => this.config, pythinkerRequestHeaders: this.pythinkerRequestHeaders, - resolveOAuthTokenProvider: this.resolveOAuthTokenProvider, promptCacheKey: sessionId, }); } private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined { - const pluginServers = this.withManagedKimiPluginEnv(this.plugins.enabledMcpServers()); + const pluginServers = this.plugins.enabledMcpServers(); if (Object.keys(pluginServers).length === 0) return base; return { servers: { @@ -1141,35 +1129,6 @@ export class PythinkerCore implements PromisableMethods { }; } - private withManagedKimiPluginEnv( - pluginServers: Record, - ): Record { - const managedEnv = this.managedPythinkerCodeEnvForPlugins(); - if (Object.keys(managedEnv).length === 0) return pluginServers; - - const out: Record = {}; - for (const [name, server] of Object.entries(pluginServers)) { - out[name] = - server.transport === 'stdio' - ? { ...server, env: { ...server.env, ...managedEnv } } - : server; - } - return out; - } - - private managedPythinkerCodeEnvForPlugins(): Record { - const provider = this.config.providers[KIMI_CODE_PROVIDER_NAME]; - const envBaseUrl = process.env[PYTHINKER_CODE_BASE_URL_ENV]; - const envOAuthHost = - process.env[PYTHINKER_CODE_OAUTH_HOST_ENV] ?? process.env[PYTHINKER_OAUTH_HOST_ENV]; - const hasEnvOverride = envBaseUrl !== undefined || envOAuthHost !== undefined; - const baseUrl = envBaseUrl !== undefined ? envBaseUrl.replace(/\/+$/, '') : provider?.baseUrl; - const oauthHost = hasEnvOverride ? envOAuthHost : provider?.oauth?.oauthHost; - const env: Record = {}; - if (baseUrl !== undefined) env[PYTHINKER_CODE_BASE_URL_ENV] = baseUrl; - if (oauthHost !== undefined) env[PYTHINKER_CODE_OAUTH_HOST_ENV] = oauthHost; - return env; - } private sessionApi(sessionId: string): SessionAPIImpl { const session = this.sessions.get(sessionId); @@ -1322,7 +1281,6 @@ async function readConfigContents(path: string): Promise { async function createRuntimeConfig(input: { readonly config: PythinkerConfig; readonly pythinkerRequestHeaders?: Record | undefined; - readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; }): Promise { const localFetcher = new LocalFetchURLProvider(); const searchService = input.config.services?.pythoughtsSearch; @@ -1336,7 +1294,7 @@ async function createRuntimeConfig(input: { baseUrl: fetchService.baseUrl, localFallback: localFetcher, defaultHeaders: input.pythinkerRequestHeaders, - ...serviceCredentials(fetchService, input.resolveOAuthTokenProvider), + ...serviceCredentials(fetchService), }), webSearcher: searchService?.baseUrl === undefined @@ -1344,26 +1302,17 @@ async function createRuntimeConfig(input: { : new PythoughtsWebSearchProvider({ baseUrl: searchService.baseUrl, defaultHeaders: input.pythinkerRequestHeaders, - ...serviceCredentials(searchService, input.resolveOAuthTokenProvider), + ...serviceCredentials(searchService), }), }; } -function serviceCredentials( - service: PythoughtsServiceConfig, - resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined, -): { +function serviceCredentials(service: PythoughtsServiceConfig): { readonly apiKey?: string | undefined; - readonly tokenProvider?: BearerTokenProvider | undefined; readonly customHeaders?: Record | undefined; } { - const apiKey = nonEmptyString(service.apiKey); return { - apiKey, - tokenProvider: - service.oauth !== undefined - ? resolveOAuthTokenProvider?.(KIMI_CODE_PROVIDER_NAME, service.oauth) - : undefined, + apiKey: nonEmptyString(service.apiKey), customHeaders: service.customHeaders, }; } diff --git a/packages/agent-core/src/services/auth/managedAuth.ts b/packages/agent-core/src/services/auth/managedAuth.ts deleted file mode 100644 index 4e66acf7..00000000 --- a/packages/agent-core/src/services/auth/managedAuth.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { readConfigFile, writeConfigFile } from '../../config'; -import type { PythinkerConfig, OAuthRef } from '../../config'; -import type { OAuthTokenProviderResolver } from '../../session/provider-manager'; -import { - applyManagedKimiCodeConfig, - applyManagedKimiCodeLogoutConfig, - KIMI_CODE_PROVIDER_NAME, - PythinkerOAuthToolkit, - resolveKimiCodeLoginAuth, - resolveKimiCodeRuntimeAuth, - type BearerTokenProvider, - type PythinkerOAuthLoginOptions, - type ManagedKimiConfigShape, -} from '@pythoughts/pythinker-code-oauth'; - -import type { IEnvironmentService } from '../environment/environment'; - -type ServicesManagedConfig = PythinkerConfig & ManagedKimiConfigShape; - -type ServicesAuthLoginOptions = Omit; - -interface ServicesAuthLoginResult { - readonly providerName: string; - readonly ok: true; - readonly defaultModel: string; - readonly defaultThinking: boolean; - readonly configPath?: string | undefined; -} - -interface ServicesAuthLogoutResult { - readonly providerName: string; - readonly ok: true; -} - -export interface ServicesAuthFacade { - login( - providerName?: string | undefined, - options?: ServicesAuthLoginOptions, - ): Promise; - logout(providerName?: string | undefined): Promise; - getCachedAccessToken( - providerName?: string, - oauthRef?: OAuthRef | undefined, - ): Promise; - readonly resolveOAuthTokenProvider: OAuthTokenProviderResolver; -} - -class ServicesManagedAuthFacade implements ServicesAuthFacade { - private readonly toolkit: PythinkerOAuthToolkit; - - constructor( - private readonly options: Pick, - ) { - this.toolkit = new PythinkerOAuthToolkit({ - homeDir: options.homeDir, - configAdapter: { - configPath: options.configPath, - read: () => readConfigFile(options.configPath) as ServicesManagedConfig, - write: async (config) => { - await writeConfigFile(options.configPath, config); - }, - apply: applyManagedKimiCodeConfig, - remove: applyManagedKimiCodeLogoutConfig, - }, - }); - } - - async login( - providerName: string | undefined = KIMI_CODE_PROVIDER_NAME, - options: ServicesAuthLoginOptions = {}, - ): Promise { - const auth = this.resolveManagedAuth(providerName); - const loginAuth = resolveKimiCodeLoginAuth({ - configuredBaseUrl: auth.baseUrl, - configuredOAuthRef: auth.oauthRef, - requestedBaseUrl: options.baseUrl, - requestedOAuthHost: options.oauthHost, - }); - const result = await this.toolkit.login(providerName, { - ...options, - baseUrl: loginAuth.baseUrl, - oauthHost: loginAuth.oauthHost, - oauthRef: options.oauthRef ?? loginAuth.oauthRef, - provisionConfig: true, - }); - if (result.provision === undefined) { - throw new Error('Pythinker auth login did not provision model config.'); - } - return { - providerName: result.providerName, - ok: true, - defaultModel: result.provision.defaultModel, - defaultThinking: result.provision.defaultThinking, - configPath: result.provision.configPath, - }; - } - - async logout( - providerName?: string | undefined, - ): Promise { - const result = await this.toolkit.logout( - providerName, - this.resolveRuntimeManagedAuth(providerName).oauthRef, - ); - return { - providerName: result.providerName, - ok: result.ok, - }; - } - - async getCachedAccessToken( - providerName?: string, - oauthRef?: OAuthRef | undefined, - ): Promise { - return this.toolkit.getCachedAccessToken( - providerName, - this.runtimeOAuthRef(providerName, oauthRef), - ); - } - - readonly resolveOAuthTokenProvider = ( - providerName: string, - oauthRef?: OAuthRef | undefined, - ): BearerTokenProvider => { - return this.toolkit.tokenProvider( - providerName, - this.runtimeOAuthRef(providerName, oauthRef), - ); - }; - - private resolveManagedAuth(providerName?: string | undefined): { - readonly oauthRef?: OAuthRef | undefined; - readonly baseUrl?: string | undefined; - } { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const config = readConfigFile(this.options.configPath); - const provider = config.providers[name]; - return { - oauthRef: provider?.oauth, - baseUrl: provider?.baseUrl, - }; - } - - private resolveRuntimeManagedAuth(providerName?: string | undefined): { - readonly oauthRef: OAuthRef; - readonly baseUrl?: string | undefined; - } { - const auth = this.resolveManagedAuth(providerName); - return resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: auth.baseUrl, - configuredOAuthRef: auth.oauthRef, - }); - } - - private runtimeOAuthRef( - providerName: string | undefined, - oauthRef?: OAuthRef | undefined, - ): OAuthRef | undefined { - if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) { - return oauthRef; - } - const auth = this.resolveManagedAuth(providerName); - return resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: auth.baseUrl, - configuredOAuthRef: oauthRef ?? auth.oauthRef, - }).oauthRef; - } -} - -export function createManagedAuthFacade( - env: Pick, -): ServicesAuthFacade { - return new ServicesManagedAuthFacade(env); -} diff --git a/packages/agent-core/src/services/authSummary/authSummaryService.ts b/packages/agent-core/src/services/authSummary/authSummaryService.ts index ff35e5cb..70b926e3 100644 --- a/packages/agent-core/src/services/authSummary/authSummaryService.ts +++ b/packages/agent-core/src/services/authSummary/authSummaryService.ts @@ -2,12 +2,9 @@ * `AuthSummaryService` — implementation of `IAuthSummaryService`. */ -import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { Disposable, InstantiationType, registerSingleton } from '../../di'; import { resolveProviderApiKey, type PythinkerConfig } from '../../config'; import type { AuthSummary } from '@pythoughts/protocol'; -import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; -import { IEnvironmentService } from '../environment/environment'; import { ICoreProcessService } from '../coreProcess/coreProcess'; import { IAuthSummaryService, @@ -16,20 +13,11 @@ import { AuthModelNotResolvedError, } from './authSummary'; - -export class AuthSummaryService - extends Disposable - implements IAuthSummaryService { +export class AuthSummaryService extends Disposable implements IAuthSummaryService { readonly _serviceBrand: undefined; - private readonly _authFacade: ServicesAuthFacade; - - constructor( - @IEnvironmentService private readonly env: IEnvironmentService, - @ICoreProcessService private readonly core: ICoreProcessService, - ) { + constructor(@ICoreProcessService private readonly core: ICoreProcessService) { super(); - this._authFacade = createManagedAuthFacade(env); } async get(): Promise { @@ -38,19 +26,7 @@ export class AuthSummaryService const providers_count = Object.keys(providers).length; const default_model = nonEmpty(config.defaultModel); - let managed_provider: AuthSummary['managed_provider'] = null; - if (providers[KIMI_CODE_PROVIDER_NAME] !== undefined) { - const hasToken = await this._hasCachedToken(KIMI_CODE_PROVIDER_NAME); - managed_provider = { - name: KIMI_CODE_PROVIDER_NAME, - status: hasToken ? 'authenticated' : 'unauthenticated', - }; - } - - let ready = - providers_count >= 1 && - default_model !== null && - (managed_provider === null || managed_provider.status !== 'revoked'); + let ready = providers_count >= 1 && default_model !== null; if (ready && default_model !== null) { const alias = config.models?.[default_model]; @@ -61,7 +37,7 @@ export class AuthSummaryService } } - return { ready, providers_count, default_model, managed_provider }; + return { ready, providers_count, default_model }; } async ensureReady(modelOverride?: string): Promise { @@ -93,12 +69,6 @@ export class AuthSummaryService if (resolveProviderApiKey(providerConfig) !== undefined) return; - if (providerConfig.oauth !== undefined) { - const hasToken = await this._hasCachedToken(providerName); - if (hasToken) return; - throw new AuthTokenMissingError(providerName); - } - throw new AuthTokenMissingError(providerName); } @@ -111,26 +81,13 @@ export class AuthSummaryService private async _readConfig(): Promise { // `reload: true` forces PythinkerCore to re-read `config.toml` from disk - // before returning. Critical for the auth probe path: writes from - // `OAuthService` (toolkit's provisioning) and `IProviderService` - // future RW endpoints land on disk via `writeConfigFile`, but - // PythinkerCore's `this.config` only refreshes when something explicitly - // asks for `reload`. Without this flag, `GET /v1/auth` would stay - // `ready:false` for the entire daemon lifetime after first login. + // before returning. Critical for the auth probe path: a login writes to + // disk via `writeConfigFile`, but PythinkerCore's `this.config` only + // refreshes when something explicitly asks for `reload`. Without this + // flag, `GET /v1/auth` would stay `ready:false` for the entire daemon + // lifetime after first login. return this.core.rpc.getPythinkerConfig({ reload: true }); } - - private async _hasCachedToken(providerName: string): Promise { - try { - const token = await this._authFacade.getCachedAccessToken(providerName); - return typeof token === 'string' && token.trim().length > 0; - } catch { - // FileTokenStorage throws if the credential dir or file is unreadable; - // treat any failure as "no token" so callers don't block on transient - // filesystem errors. - return false; - } - } } function nonEmpty(value: string | undefined): string | null { @@ -140,7 +97,7 @@ function nonEmpty(value: string | undefined): string | null { } // Self-register under the global singleton registry. All ctor deps are -// `@I…`-injected (@IEnvironmentService / @ICoreProcessService); -// `staticArguments = []`. `supportsDelayedInstantiation = false` preserves -// current reverse-dispose semantics. +// `@I…`-injected (@ICoreProcessService); `staticArguments = []`. +// `supportsDelayedInstantiation = false` preserves current reverse-dispose +// semantics. registerSingleton(IAuthSummaryService, AuthSummaryService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/services/coreProcess/coreProcessService.ts b/packages/agent-core/src/services/coreProcess/coreProcessService.ts index 06cf263c..9c8ff29c 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessService.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcessService.ts @@ -5,13 +5,11 @@ import { createRPC, PythinkerCore } from '../../rpc'; import { Disposable, registerSingleton, SyncDescriptor } from '../../di'; import type { CoreAPI, CoreRPC, SDKAPI } from '../../rpc'; -import type { OAuthTokenProviderResolver } from '../../session/provider-manager'; import { createPythinkerDefaultHeaders, type PythinkerHostIdentity, } from '@pythoughts/pythinker-code-oauth'; -import { createManagedAuthFacade } from '../auth/managedAuth'; import { BridgeClientAPI } from './coreProcessClient'; import { IApprovalService } from '../approval/approval'; import { IEnvironmentService } from '../environment/environment'; @@ -67,21 +65,6 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic // function PythinkerCore receives, `sdkRpc` is the one we satisfy. const [coreRpc, sdkRpc] = createRPC(); - // Default-wire the OAuth token resolver. Without this, PythinkerCore's - // `ProviderManager.resolveAuth` sees `resolveOAuthTokenProvider === - // undefined` and synthesizes a closure that ALWAYS throws - // `AUTH_LOGIN_REQUIRED` — even after a successful device-code login that - // persisted a fresh token to disk. The daemon's `/auth` readiness probe - // is a different code path (file existence on the credentials store) so - // it stays green; the failure only surfaces inside the prompt turn, as - // an `auth.login_required` error after `turn.step.started`. We bridge - // the gap by default-constructing a managed auth facade against the same - // home + config paths PythinkerCore will use, and handing its - // `resolveOAuthTokenProvider` into the core. Callers (e.g. node-sdk - // tests) can still override via `options.resolveOAuthTokenProvider`. - const resolveOAuthTokenProvider: OAuthTokenProviderResolver = - options.resolveOAuthTokenProvider ?? - CoreProcessService._defaultOAuthTokenResolver(env.homeDir, env.configPath); // Default-wire the Pythinker request headers (User-Agent + X-Msh-* device // identity). Without this, PythinkerCore's outbound fetch carries the @@ -114,7 +97,6 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic configPath: env.configPath, pythinkerRequestHeaders, appVersion, - resolveOAuthTokenProvider, resolveWorkspaceId, }); @@ -183,23 +165,6 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic }); } - /** - * Build the default `resolveOAuthTokenProvider` from the same home + config - * paths PythinkerCore resolves internally. Mirrors `SDKRpcClient`'s default in - * `packages/node-sdk/src/sdk-rpc-client.ts` so the daemon and the SDK - * runtimes share OAuth credentials when both run against the same - * `~/.pythinker-code`. - * - * Exposed as `static` so tests can assert the wiring without exercising the - * full agent-core turn loop. - */ - static _defaultOAuthTokenResolver( - homeDir: string, - configPath: string, - ): OAuthTokenProviderResolver { - const facade = createManagedAuthFacade({ homeDir, configPath }); - return facade.resolveOAuthTokenProvider; - } /** * Build the default `pythinkerRequestHeaders` from `options.identity` so the diff --git a/packages/agent-core/src/services/index.ts b/packages/agent-core/src/services/index.ts index a76c6d95..78cbbae7 100644 --- a/packages/agent-core/src/services/index.ts +++ b/packages/agent-core/src/services/index.ts @@ -105,8 +105,6 @@ export { } from './authSummary/authSummary'; export { AuthSummaryService } from './authSummary/authSummaryService'; -export { IOAuthService } from './oauth/oauth'; -export { OAuthService } from './oauth/oauthService'; export { IModelCatalogService, diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts index 0db57f36..d58c17db 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalog.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalog.ts @@ -3,7 +3,6 @@ import type { PythinkerConfig, ModelAlias, ProviderConfig } from '../../config'; import type { ModelCatalogItem, ProviderCatalogItem, - RefreshOAuthProviderModelsResponse, SetDefaultModelResponse, } from '@pythoughts/protocol'; @@ -14,7 +13,6 @@ export interface IModelCatalogService { listProviders(): Promise; getProvider(providerId: string): Promise; setDefaultModel(modelId: string): Promise; - refreshOAuthProviderModels(): Promise; } // eslint-disable-next-line @typescript-eslint/no-redeclare diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index 16fd422d..4a7aab4d 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -2,27 +2,14 @@ import { Disposable, InstantiationType, registerSingleton } from '../../di'; import { resolveProviderApiKey, type PythinkerConfig, - type ModelAlias, type ProviderConfig, } from '../../config'; import type { ModelCatalogItem, ProviderCatalogItem, - RefreshOAuthProviderModelsResponse, SetDefaultModelResponse, } from '@pythoughts/protocol'; -import { - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, - applyManagedKimiCodeConfig, - fetchManagedKimiCodeModels, - resolveKimiCodeRuntimeAuth, - type ManagedKimiConfigShape, -} from '@pythoughts/pythinker-code-oauth'; - -import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; import { ICoreProcessService } from '../coreProcess/coreProcess'; -import { IEnvironmentService } from '../environment/environment'; import { IModelCatalogService, ModelNotFoundError, @@ -36,24 +23,8 @@ export class ModelCatalogService implements IModelCatalogService { readonly _serviceBrand: undefined; - private _authFacade: ServicesAuthFacade; - - constructor( - @IEnvironmentService env: IEnvironmentService, - @ICoreProcessService private readonly core: ICoreProcessService, - ) { + constructor(@ICoreProcessService private readonly core: ICoreProcessService) { super(); - this._authFacade = createManagedAuthFacade(env); - } - - static _createForTest( - env: IEnvironmentService, - core: ICoreProcessService, - authFacade: ServicesAuthFacade, - ): ModelCatalogService { - const service = new ModelCatalogService(env, core); - service._authFacade = authFacade; - return service; } async listModels(): Promise { @@ -96,86 +67,6 @@ export class ModelCatalogService }; } - async refreshOAuthProviderModels(): Promise { - let config = await this._readConfig(); - const changed: RefreshOAuthProviderModelsResponse['changed'] = []; - const unchanged: string[] = []; - const failed: RefreshOAuthProviderModelsResponse['failed'] = []; - const provider = config.providers?.[KIMI_CODE_PROVIDER_NAME]; - if (provider?.type !== 'pythinker' || provider.oauth === undefined) { - return { changed, unchanged, failed }; - } - - try { - const auth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: provider.baseUrl, - configuredOAuthRef: provider.oauth, - }); - const tokenProvider = this._authFacade.resolveOAuthTokenProvider( - KIMI_CODE_PROVIDER_NAME, - auth.oauthRef, - ); - if (tokenProvider === undefined) { - throw new Error('OAuth token provider is not configured.'); - } - const token = await tokenProvider.getAccessToken(); - const models = await fetchManagedKimiCodeModels({ - accessToken: token, - baseUrl: auth.baseUrl, - }); - if (models.length === 0) return { changed, unchanged, failed }; - - const next = structuredClone(config); - applyManagedKimiCodeConfig(next as unknown as ManagedKimiConfigShape, { - 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); - - 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 this.core.rpc.removePythinkerProvider({ providerId: KIMI_CODE_PROVIDER_NAME }); - await this.core.rpc.setPythinkerConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - provider_id: KIMI_CODE_PROVIDER_NAME, - provider_name: 'Pythinker Code', - added, - removed, - }); - } - } catch (error) { - failed.push({ - provider: KIMI_CODE_PROVIDER_NAME, - reason: error instanceof Error ? error.message : String(error), - }); - } - - return { changed, unchanged, failed }; - } private async _readConfig(): Promise { return this.core.rpc.getPythinkerConfig({ reload: true }); @@ -187,158 +78,24 @@ export class ModelCatalogService provider: ProviderConfig, ): Promise { const hasApiKey = resolveProviderApiKey(provider) !== undefined; - const hasOAuthToken = await this._hasCachedToken(providerId, provider); return toProtocolProvider(providerId, provider, config, { hasApiKey, - hasOAuthToken, + hasOAuthToken: false, }); } - private async _hasCachedToken( - providerId: string, - provider: ProviderConfig, - ): Promise { - if (provider.oauth === undefined) return false; - try { - const token = await this._authFacade.getCachedAccessToken( - providerId, - provider.oauth, - ); - return nonEmpty(token) !== undefined; - } catch { - return false; - } - } } -function collectModelIdsForAliases(config: PythinkerConfig, aliasKeys: ReadonlySet): Set { - const ids = new Set(); - for (const aliasKey of aliasKeys) { - const alias = config.models?.[aliasKey]; - if (alias !== undefined && alias.model.length > 0) ids.add(alias.model); - } - return ids; -} -function providerAliasKeys(config: PythinkerConfig, providerId: string): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId) keys.add(alias); - } - return keys; -} -function generatedProviderAliasKeys( - config: PythinkerConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = new Set(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId && alias.startsWith(aliasPrefix)) keys.add(alias); - } - return keys; -} -function computeChanges(oldIds: Set, newIds: Set): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; -} -function providerModelsEqual( - config: PythinkerConfig, - nextConfig: PythinkerConfig, - providerId: string, - aliasKeys: ReadonlySet, -): boolean { - return ( - providerModelSnapshot(config, providerId, aliasKeys) === - providerModelSnapshot(nextConfig, providerId, aliasKeys) - ); -} -function providerModelSnapshot( - config: PythinkerConfig, - providerId: string, - aliasKeys: ReadonlySet, -): string { - const snapshots: Array<{ alias: string; model: ModelAlias }> = []; - for (const alias of aliasKeys) { - const model = config.models?.[alias]; - if (model === undefined || model.provider !== providerId) continue; - snapshots.push({ - alias, - model: { - ...model, - capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(), - }, - }); - } - snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); -} -function providerRefreshAliasKeys( - config: PythinkerConfig, - nextConfig: PythinkerConfig, - providerId: string, - aliasPrefix: string, -): Set { - const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); - for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); - return keys; -} -function preserveUserProviderAliases( - config: PythinkerConfig, - providerId: string, - refreshedAliasKeys: ReadonlySet, -): Record { - const preserved: Record = {}; - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; - preserved[alias] = structuredClone(model); - } - return preserved; -} -function restoreProviderAliases(config: PythinkerConfig, aliases: Record): void { - if (Object.keys(aliases).length === 0) return; - config.models = { - ...config.models, - ...aliases, - }; -} -function restoreDefaultSelection( - config: PythinkerConfig, - defaultModel: string | undefined, - defaultThinking: boolean | undefined, -): void { - if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return; - config.defaultModel = defaultModel; - const capabilities = config.models[defaultModel]?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; -} -function clampDanglingDefault(config: PythinkerConfig): void { - if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { - config.defaultModel = undefined; - config.defaultThinking = undefined; - } -} -function nonEmpty(value: string | undefined): string | undefined { - if (value === undefined) return undefined; - const trimmed = value.trim(); - return trimmed.length === 0 ? undefined : trimmed; -} registerSingleton(IModelCatalogService, ModelCatalogService, InstantiationType.Delayed); diff --git a/packages/agent-core/src/services/oauth/oauth.ts b/packages/agent-core/src/services/oauth/oauth.ts deleted file mode 100644 index a919491f..00000000 --- a/packages/agent-core/src/services/oauth/oauth.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * `IOAuthService` — daemon-facing device-code login orchestration. - * - * Bridges the OAuth toolkit's `login({onDeviceCode})` callback shape to a - * REST resource: the frontend POSTs to start, gets a `verification_uri` - * synchronously, then polls a GET endpoint for status transitions while the - * daemon polls the OAuth host in the background. - * - * **One in-flight flow per provider**. A second start cancels - * the existing pending flow first (transitions it to `'cancelled'`) then - * mints a fresh `flow_id`. Completed flows live in-memory for 5 min so the - * frontend's last poll lands on the terminal status; after that, they GC - * and `getFlow()` returns `undefined`. - * - * **No client coupling**. Daemon does NOT detect frontend exit - * / WS disconnect. Cleanup paths: - * 1. 15-min upstream timeout (DeviceCodeTimeoutError → 'expired') - * 2. Explicit `cancelLogin()` (→ 'cancelled') - * 3. Same-provider new flow superseding (→ 'cancelled') - * - * **Token + config** land via the toolkit's provisioning path: on success, - * the `managed:kimi-code` provider + models entry are written to - * `config.toml`, and the cached token is saved to credentials. Frontend - * follow-up: hit `GET /v1/auth` to confirm `ready: true`. - * - * **Architecture**: - * - * POST /v1/oauth/login - * │ - * ▼ - * startLogin() ──┐ - * │ managed auth facade login runs in BACKGROUND - * ▼ │ - * ┌─ onDeviceCode(auth) ◄────────────────────┘ (fires once) - * │ │ - * │ └─ resolves a deferred capturing the verification URLs - * │ - * ▼ - * REST handler returns OAuthFlowStart immediately - * - * meanwhile, the background facade.login() polls... - * - * ┌─ resolves with PythinkerAuthLoginResult → flow status = 'authenticated' - * │ + config.toml provisioned - * │ + token saved to credentials - * │ - * └─ rejects with one of: - * DeviceCodeTimeoutError → 'expired' - * OAuthError("denied") → 'denied' - * OAuthError("aborted") → 'cancelled' - * other → 'denied' (generic failure) - * - * GET /v1/oauth/login → getFlow() → snapshot of in-memory state - * - * **One in-flight per provider**: startLogin replaces an - * existing pending flow by aborting its AbortController + flipping its - * status to 'cancelled' BEFORE minting a new flow_id. - * - * **GC**: a 5-min timer fires after each terminal transition; the entry is - * dropped on timer fire. Pending flows have no GC — they live until the - * upstream 15-min device_code TTL expires + facade.login resolves with - * `DeviceCodeTimeoutError`. - */ - -import { createDecorator } from '../../di'; -import type { - OAuthFlowSnapshot, - OAuthFlowStart, - OAuthLoginCancelResponse, - OAuthLogoutResponse, -} from '@pythoughts/protocol'; - -export interface IOAuthService { - readonly _serviceBrand: undefined; - - /** - * Kick off a device-code flow for `providerName` (default - * `'managed:kimi-code'`). Requests the device authorization synchronously - * (1-2 round-trips to the OAuth host), starts background polling, and - * returns the verification URLs + flow_id. - * - * Cancels any existing pending flow for the same provider before starting. - */ - startLogin(providerName?: string): Promise; - - /** - * Snapshot the current flow state for `providerName`. Returns `undefined` - * when no flow has been started (or was GC'd after 5 min in terminal state). - */ - getFlow(providerName?: string): OAuthFlowSnapshot | undefined; - - /** - * Cancel a pending flow. Idempotent: cancelling a terminal flow returns - * `{cancelled: false, status: }` instead of throwing. - */ - cancelLogin(providerName?: string): Promise; - - /** - * Logout — delete the stored token + strip the managed provider's - * `apply` config entries (provider + models). After this, `GET /v1/auth` - * flips to `ready: false`. - */ - logout(providerName?: string): Promise; -} - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const IOAuthService = createDecorator('oauthService'); diff --git a/packages/agent-core/src/services/oauth/oauthService.ts b/packages/agent-core/src/services/oauth/oauthService.ts deleted file mode 100644 index 7f789fc2..00000000 --- a/packages/agent-core/src/services/oauth/oauthService.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * `OAuthService` — implementation of `IOAuthService`. - */ - -import { Disposable, DisposableMap, InstantiationType, registerSingleton } from '../../di'; -import type { IDisposable } from '../../di'; -import { - DeviceCodeTimeoutError, - KIMI_CODE_PROVIDER_NAME, - OAuthError, - type DeviceAuthorization, -} from '@pythoughts/pythinker-code-oauth'; -import type { - OAuthFlowSnapshot, - OAuthFlowStart, - OAuthFlowStatus, - OAuthLoginCancelResponse, - OAuthLogoutResponse, -} from '@pythoughts/protocol'; -import { ulid } from 'ulid'; - -import { createManagedAuthFacade, type ServicesAuthFacade } from '../auth/managedAuth'; -import { IEnvironmentService } from '../environment/environment'; -import { IOAuthService } from './oauth'; - -/** - * One in-flight (or recently-completed) device-code flow. Stored in - * `OAuthService._flows` (a `DisposableMap`) so: - * - `_flows.set(provider, newState)` auto-disposes the supersedee - * - `_flows.deleteAndDispose(provider)` (called from the GC timer) tears - * down a terminal entry's leftover state - * - service-wide `super.dispose()` walks every entry's `dispose()` - * instead of the old hand-written for-loop in `override dispose()`. - * - * `dispose()` is idempotent and aborts the controller only when the flow is - * still pending — terminal flows have already returned from their underlying - * promise, so a second abort would be a noisy no-op. - */ -class FlowState implements IDisposable { - status: OAuthFlowStatus = 'pending'; - resolvedAt?: number; - errorMessage?: string; - gcTimer?: NodeJS.Timeout; - private _disposed = false; - - constructor( - readonly flowId: string, - readonly provider: string, - readonly deviceAuth: DeviceAuthorization, - /** Resolved seconds-until-expiry (may differ from `deviceAuth.expiresIn` if that was null). */ - readonly expiresInSec: number, - readonly startedAt: number, - readonly expiresAt: number, - readonly controller: AbortController, - ) {} - - dispose(): void { - if (this._disposed) return; - this._disposed = true; - if (this.gcTimer !== undefined) { - clearTimeout(this.gcTimer); - this.gcTimer = undefined; - } - if (this.status === 'pending') { - try { - this.controller.abort(); - } catch { - // ignore - } - } - } -} - -/** Terminal flows live this long after resolution before GC. */ -const TERMINAL_RETENTION_MS = 5 * 60 * 1000; - -export class OAuthService extends Disposable implements IOAuthService { - readonly _serviceBrand: undefined; - - private readonly _authFacade: ServicesAuthFacade; - private readonly _flows: DisposableMap; - - constructor(@IEnvironmentService private readonly env: IEnvironmentService) { - super(); - this._flows = this._register(new DisposableMap()); - this._authFacade = createManagedAuthFacade(env); - } - - /** @internal Test-only factory that injects a mock facade. */ - static _createForTest(env: IEnvironmentService, facade: ServicesAuthFacade): OAuthService { - const svc = new (OAuthService as any)(env) as OAuthService; - (svc as any)._authFacade = facade; - return svc; - } - - async startLogin(providerName?: string): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - - // Supersede any existing pending flow. - const existing = this._flows.get(name); - if (existing !== undefined && existing.status === 'pending') { - existing.controller.abort(); - this._setTerminal(existing, 'cancelled'); - } - - const flowId = `oauth_${ulid()}`; - const controller = new AbortController(); - - // Capture the device authorization via a deferred. The managed auth facade - // calls `onDeviceCode` exactly once, then starts polling. We resolve the - // deferred from inside the callback so this method can return as soon as - // the URLs are known — well before the polling completes. - let resolveAuth: (d: DeviceAuthorization) => void; - let rejectAuth: (e: unknown) => void; - const authPromise = new Promise((resolve, reject) => { - resolveAuth = resolve; - rejectAuth = reject; - }); - - // Background login — DO NOT await. We hand the controller's signal in so - // `cancelLogin()` and the supersede path can interrupt mid-poll. - const loginPromise = this._authFacade.login(name, { - signal: controller.signal, - onDeviceCode: (auth) => { - resolveAuth(auth); - }, - }); - - // Surface a synchronous failure (device-auth request itself fails before - // `onDeviceCode` fires) by racing the login promise. - loginPromise.catch((error) => { - rejectAuth(error); - }); - - let deviceAuth: DeviceAuthorization; - try { - deviceAuth = await authPromise; - } catch (error) { - // The OAuth host or the network broke before we got a device code. - // No flow state was registered yet; just surface the error to the - // REST handler → 50001. - const msg = error instanceof Error ? error.message : String(error); - throw new OAuthError(`failed to start device flow: ${msg}`); - } - - const startedAt = Date.now(); - // `expiresIn` is server-reported and may be null (RFC 8628 §3.2 allows - // omission). Fall back to the local 15-min budget enforced by - // `OAuthManager.login`, so the `expires_at` we surface to clients is - // never further out than the deadline that's actually being enforced. - const expiresInSec = deviceAuth.expiresIn ?? 15 * 60; - const state = new FlowState( - flowId, - name, - deviceAuth, - expiresInSec, - startedAt, - startedAt + expiresInSec * 1000, - controller, - ); - this._flows.set(name, state); - - // Wire the background promise's terminal transition. We branch on error - // class + message — see the file header for the mapping. - loginPromise.then( - () => this._handleSuccess(state), - (error) => this._handleFailure(state, error), - ); - - return { - flow_id: flowId, - provider: name, - verification_uri: deviceAuth.verificationUri, - verification_uri_complete: deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri, - user_code: deviceAuth.userCode, - expires_in: expiresInSec, - interval: deviceAuth.interval, - status: 'pending', - expires_at: new Date(state.expiresAt).toISOString(), - }; - } - - getFlow(providerName?: string): OAuthFlowSnapshot | undefined { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const state = this._flows.get(name); - if (state === undefined) return undefined; - return this._toSnapshot(state); - } - - async cancelLogin(providerName?: string): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const state = this._flows.get(name); - if (state === undefined) { - // No flow at all → treat as "already cancelled" (idempotent). - return { cancelled: false, status: 'cancelled' }; - } - if (state.status !== 'pending') { - return { cancelled: false, status: state.status }; - } - state.controller.abort(); - this._setTerminal(state, 'cancelled'); - return { cancelled: true, status: 'cancelled' }; - } - - async logout(providerName?: string): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - // Also cancel any in-flight flow so the next `GET /v1/auth` sees a clean - // slate. - const pending = this._flows.get(name); - if (pending !== undefined && pending.status === 'pending') { - pending.controller.abort(); - this._setTerminal(pending, 'cancelled'); - } - const result = await this._authFacade.logout(name); - return { logged_out: true, provider: result.providerName }; - } - - override dispose(): void { - if (this._store.isDisposed) return; - super.dispose(); - } - - /* ----------------------------- internals ---------------------------- */ - - private _handleSuccess(state: FlowState): void { - if (state.status !== 'pending') return; // already cancelled / superseded - this._setTerminal(state, 'authenticated'); - } - - private _handleFailure(state: FlowState, err: unknown): void { - if (state.status !== 'pending') return; // already cancelled / superseded - - const status = classifyFailure(err); - const message = err instanceof Error ? err.message : String(err); - state.errorMessage = message; - this._setTerminal(state, status); - } - - private _setTerminal(state: FlowState, status: OAuthFlowStatus): void { - if (state.status === status) return; - state.status = status; - state.resolvedAt = Date.now(); - // Schedule GC. If a new flow supersedes this entry first, the supersede - // path runs `_flows.set(name, newState)` which auto-disposes this entry — - // its `dispose()` clears `gcTimer` before the timer can fire, so the - // callback can rely on "this state is still the current map entry" - // without the equality check needing a stale-guard. - if (state.gcTimer !== undefined) clearTimeout(state.gcTimer); - state.gcTimer = setTimeout(() => { - const current = this._flows.get(state.provider); - // Belt-and-suspenders: even though dispose() clears the timer on - // overwrite, keep the identity check in case the timer was queued - // before the clearTimeout took effect. - if (current === state) this._flows.deleteAndDispose(state.provider); - }, TERMINAL_RETENTION_MS); - // Don't keep the process alive solely for GC. - state.gcTimer.unref?.(); - } - - private _toSnapshot(state: FlowState): OAuthFlowSnapshot { - const snap: OAuthFlowSnapshot = { - flow_id: state.flowId, - provider: state.provider, - status: state.status, - verification_uri: state.deviceAuth.verificationUri, - verification_uri_complete: - state.deviceAuth.verificationUriComplete ?? state.deviceAuth.verificationUri, - user_code: state.deviceAuth.userCode, - expires_in: state.expiresInSec, - expires_at: new Date(state.expiresAt).toISOString(), - interval: state.deviceAuth.interval, - }; - if (state.resolvedAt !== undefined) { - (snap as { resolved_at?: string }).resolved_at = new Date( - state.resolvedAt, - ).toISOString(); - } - if (state.errorMessage !== undefined) { - (snap as { error_message?: string }).error_message = state.errorMessage; - } - return snap; - } -} - -/** - * Map the error thrown by the background login promise to a terminal status. - * - * - `DeviceCodeTimeoutError` → 'expired' (the 15-min budget ran out) - * - `OAuthError` whose message starts with 'Login aborted' → 'cancelled' - * (our own AbortController fired or the toolkit's signal path) - * - `OAuthError` mentioning 'denied' → 'denied' (user refused) - * - Anything else → 'denied' (we collapse "denied" and "generic failure"; - * the `error_message` field carries the diagnostic detail for the UI) - */ -function classifyFailure(err: unknown): OAuthFlowStatus { - if (err instanceof DeviceCodeTimeoutError) return 'expired'; - if (err instanceof OAuthError) { - const msg = err.message.toLowerCase(); - if (msg.includes('aborted')) return 'cancelled'; - if (msg.includes('denied')) return 'denied'; - return 'denied'; - } - return 'denied'; -} - -// Self-register under the global singleton registry. All ctor deps are -// `@I…`-injected (@IEnvironmentService only); `staticArguments = []`. -// `supportsDelayedInstantiation = false` preserves current reverse-dispose -// semantics. -registerSingleton(IOAuthService, OAuthService, InstantiationType.Delayed); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 0320fa01..2b8ef18b 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -22,7 +22,6 @@ import { resolveGlobalLogPath, } from '../../src/logging/logger'; import { resolveLoggingConfig } from '../../src/logging/resolve-config'; -import type { OAuthTokenProviderResolver } from '../../src/session/provider-manager'; import { testKaos } from '../fixtures/test-kaos'; function requiredFlagEnv(id: string): string { @@ -640,69 +639,6 @@ lsp = true }); }); - it('uses the shared OAuth resolver for Pythoughts service tokens', async () => { - tmp = await mkdtemp(join(tmpdir(), 'pythinker-core-runtime-')); - const homeDir = join(tmp, 'home'); - const workDir = join(tmp, 'work'); - await mkdir(homeDir, { recursive: true }); - await mkdir(workDir, { recursive: true }); - await writeFile( - join(homeDir, 'config.toml'), - ` -[services.pythoughts_search] -base_url = "https://search.example/v1" -oauth = { storage = "file", key = "oauth/custom-pythinker-code" } -custom_headers = { "X-Test" = "1" } -`, - ); - - const getAccessToken = vi.fn().mockResolvedValue('service-token'); - const resolveOAuthTokenProvider = vi.fn(() => ({ - getAccessToken, - })); - const fetchImpl = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ search_results: [] }), { - status: 200, - }), - ); - vi.stubGlobal('fetch', fetchImpl); - - const [coreRpc, sdkRpc] = createRPC(); - const core = new PythinkerCore(coreRpc, { - homeDir, - pythinkerRequestHeaders: { - 'User-Agent': 'pythinker-code-cli/0.0.0-test', - 'X-Msh-Version': '0.0.0-test', - }, - resolveOAuthTokenProvider, - }); - const rpc = await sdkRpc({ - emitEvent: vi.fn(), - requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), - requestQuestion: vi.fn(async () => null), - toolCall: vi.fn(async () => ({ output: '' })), - }); - - const created = await rpc.createSession({ id: 'ses_runtime_service_oauth', workDir }); - const session = core.sessions.get(created.id); - - expect(resolveOAuthTokenProvider).toHaveBeenCalledWith('managed:kimi-code', { - storage: 'file', - key: 'oauth/custom-pythinker-code', - }); - expect(session?.options.toolServices?.webSearcher).toBeDefined(); - - await session!.options.toolServices?.webSearcher!.search('pythinker'); - - expect(getAccessToken).toHaveBeenCalledWith(); - const init = fetchImpl.mock.calls[0]?.[1] as RequestInit; - expect(init.headers).toMatchObject({ - Authorization: 'Bearer service-token', - 'User-Agent': 'pythinker-code-cli/0.0.0-test', - 'X-Msh-Version': '0.0.0-test', - 'X-Test': '1', - }); - }); it('falls back to defaultModel when createSession receives no model option', async () => { tmp = await mkdtemp(join(tmpdir(), 'pythinker-core-runtime-')); diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index 6589f20f..fdfdfb71 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -109,63 +109,6 @@ describe('PythinkerCore plugin RPCs', () => { ); }); - it('injects persisted managed Pythinker Code environment into the datasource plugin MCP server', async () => { - const previousBaseUrl = process.env['PYTHINKER_CODE_BASE_URL']; - const previousCodeOAuthHost = process.env['PYTHINKER_CODE_OAUTH_HOST']; - const previousOAuthHost = process.env['PYTHINKER_OAUTH_HOST']; - delete process.env['PYTHINKER_CODE_BASE_URL']; - delete process.env['PYTHINKER_CODE_OAUTH_HOST']; - delete process.env['PYTHINKER_OAUTH_HOST']; - - const home = await mkdtemp(path.join(tmpdir(), 'pythinker-home-')); - const pluginRoot = await mkdtemp(path.join(tmpdir(), 'plugin-')); - try { - await writeFile( - path.join(home, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "https://api.dev.example.test/coding/v1" -api_key = "" -oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "https://auth.dev.example.test" } -`, - 'utf8', - ); - await writeFile( - path.join(pluginRoot, 'pythinker.plugin.json'), - JSON.stringify({ - name: 'pythinker-datasource', - mcpServers: { - data: { command: 'node', args: ['./bin/pythinker-datasource.mjs'] }, - }, - }), - 'utf8', - ); - - const core = new PythinkerCore(async () => ({}) as never, { homeDir: home }); - await new Promise((r) => setImmediate(r)); - await core.installPlugin({ source: pluginRoot }); - - const mcpConfig = ( - core as unknown as { - mergePluginMcpConfig(base: undefined): { - servers: Record }>; - }; - } - ).mergePluginMcpConfig(undefined); - - expect(mcpConfig.servers['plugin-pythinker-datasource:data']?.env).toEqual( - expect.objectContaining({ - PYTHINKER_CODE_BASE_URL: 'https://api.dev.example.test/coding/v1', - PYTHINKER_CODE_OAUTH_HOST: 'https://auth.dev.example.test', - }), - ); - } finally { - restoreEnv('PYTHINKER_CODE_BASE_URL', previousBaseUrl); - restoreEnv('PYTHINKER_CODE_OAUTH_HOST', previousCodeOAuthHost); - restoreEnv('PYTHINKER_OAUTH_HOST', previousOAuthHost); - } - }); it('throws PLUGIN_LOAD_FAILED on every RPC when installed.json is corrupt', async () => { const home = await mkdtemp(path.join(tmpdir(), 'pythinker-home-')); diff --git a/packages/agent-core/test/services/auth-summary-service.test.ts b/packages/agent-core/test/services/auth-summary-service.test.ts index b175f348..3e0d06b9 100644 --- a/packages/agent-core/test/services/auth-summary-service.test.ts +++ b/packages/agent-core/test/services/auth-summary-service.test.ts @@ -38,12 +38,7 @@ function makeService(): AuthSummaryService { ready: async () => undefined, dispose: () => undefined, }; - const env: IEnvironmentService = { - _serviceBrand: undefined, - homeDir: '/tmp/pythinker-auth-summary-test', - configPath: '/tmp/pythinker-auth-summary-test/config.toml', - }; - return new AuthSummaryService(env, core); + return new AuthSummaryService(core); } describe('AuthSummaryService API key environment references', () => { diff --git a/packages/agent-core/test/services/coreProcessService.test.ts b/packages/agent-core/test/services/coreProcessService.test.ts index e5ec0e09..053d3fc4 100644 --- a/packages/agent-core/test/services/coreProcessService.test.ts +++ b/packages/agent-core/test/services/coreProcessService.test.ts @@ -242,13 +242,6 @@ describe('CoreProcessService direct construction', () => { await expect(core.rpc.getCoreInfo({})).rejects.toThrow(/disposed/); }); - it('default-wires a resolveOAuthTokenProvider when caller omits one', () => { - const resolver = CoreProcessService._defaultOAuthTokenResolver(tmpHome, join(tmpHome, 'config.toml')); - expect(typeof resolver).toBe('function'); - const tokenProvider = resolver('managed:kimi-code'); - expect(tokenProvider).toBeDefined(); - expect(typeof tokenProvider?.getAccessToken).toBe('function'); - }); it('default-wires pythinkerRequestHeaders from identity when caller omits headers', () => { const headers = CoreProcessService._defaultPythinkerRequestHeaders( diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index 9fc1de54..ef0a59ca 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -7,18 +7,15 @@ import type { PythinkerConfigPatch, SetPythinkerConfigPayload, } from '../../src'; -import { KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth'; import { type ICoreProcessService, - type IEnvironmentService, ModelCatalogService, ModelNotFoundError, ProviderNotFoundError, toProtocolModel, toProtocolProvider, } from '../../src/services'; -import type { ServicesAuthFacade } from '../../src/services/auth/managedAuth'; afterEach(() => { vi.unstubAllGlobals(); @@ -26,13 +23,6 @@ afterEach(() => { vi.clearAllMocks(); }); -function makeEnv(): IEnvironmentService { - return { - _serviceBrand: undefined, - homeDir: '/tmp/pythinker-model-catalog-test', - configPath: '/tmp/pythinker-model-catalog-test/config.toml', - }; -} function makeCore(configRef: { current: PythinkerConfig }): { core: ICoreProcessService; @@ -91,16 +81,6 @@ function makeCore(configRef: { current: PythinkerConfig }): { }; } -function authFacade(accessToken = 'token-test'): ServicesAuthFacade { - return { - login: vi.fn(), - logout: vi.fn(), - getCachedAccessToken: vi.fn(async () => accessToken), - resolveOAuthTokenProvider: vi.fn(() => ({ - getAccessToken: vi.fn(async () => accessToken), - })), - }; -} function catalogConfig(): PythinkerConfig { return { @@ -181,7 +161,7 @@ describe('ModelCatalogService', () => { }; const configRef = { current: config }; const { core } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(core); await expect(svc.getProvider('openai')).resolves.toMatchObject({ id: 'openai', @@ -199,7 +179,7 @@ describe('ModelCatalogService', () => { it('lists models and providers from live config', async () => { const configRef = { current: catalogConfig() }; const { core, getCalls } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(core); expect(await svc.listModels()).toHaveLength(3); expect(await svc.listProviders()).toHaveLength(2); @@ -209,7 +189,7 @@ describe('ModelCatalogService', () => { it('gets one provider or throws ProviderNotFoundError', async () => { const configRef = { current: catalogConfig() }; const { core } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(core); await expect(svc.getProvider('pythinker')).resolves.toMatchObject({ id: 'pythinker' }); await expect(svc.getProvider('missing')).rejects.toBeInstanceOf( @@ -220,7 +200,7 @@ describe('ModelCatalogService', () => { it('sets defaultModel through core config patch', async () => { const configRef = { current: catalogConfig() }; const { core, setCalls } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(core); await expect(svc.setDefaultModel('turbo')).resolves.toEqual({ default_model: 'turbo', @@ -237,67 +217,11 @@ describe('ModelCatalogService', () => { it('rejects unknown model ids', async () => { const configRef = { current: catalogConfig() }; const { core } = makeCore(configRef); - const svc = new ModelCatalogService(makeEnv(), core); + const svc = new ModelCatalogService(core); await expect(svc.setDefaultModel('missing')).rejects.toBeInstanceOf( ModelNotFoundError, ); }); - it('refreshes managed OAuth models and preserves always-thinking defaults', async () => { - const configRef: { current: PythinkerConfig } = { - current: { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - baseUrl: 'https://api.example.test/coding/v1', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - }, - defaultModel: 'kimi-code/pythinker-for-coding', - defaultThinking: false, - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 131_072, - capabilities: ['thinking'], - }, - }, - }, - }; - const { core, removeCalls, setCalls } = makeCore(configRef); - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262_144, - supports_reasoning: true, - supports_thinking_type: 'only', - supports_image_in: false, - supports_video_in: false, - }, - ], - }))); - vi.stubGlobal('fetch', fetchMock); - const svc = ModelCatalogService._createForTest(makeEnv(), core, authFacade()); - - await expect(svc.refreshOAuthProviderModels()).resolves.toMatchObject({ - changed: [{ provider_id: KIMI_CODE_PROVIDER_NAME, added: 0, removed: 0 }], - failed: [], - }); - - expect(removeCalls).toEqual([KIMI_CODE_PROVIDER_NAME]); - expect(setCalls.at(-1)).toMatchObject({ - defaultModel: 'kimi-code/pythinker-for-coding', - defaultThinking: true, - models: { - 'kimi-code/pythinker-for-coding': { - capabilities: ['thinking', 'always_thinking', 'tool_use'], - maxContextSize: 262_144, - }, - }, - }); - }); }); diff --git a/packages/agent-core/test/services/oauth-service.test.ts b/packages/agent-core/test/services/oauth-service.test.ts deleted file mode 100644 index a6d5ce1a..00000000 --- a/packages/agent-core/test/services/oauth-service.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -/** - * `OAuthService` (P2.7) unit tests. - * - * Hermetic: a mock managed auth facade is injected so we don't need a real - * OAuth host on the network. The mock's `login()` exposes a deferred device - * authorization + completion promise so tests can drive each transition - * independently: - * - * facadeMock.deviceCodeReady(deviceAuth) → fires onDeviceCode → REST returns - * facadeMock.resolveLogin(result) → flow → 'authenticated' - * facadeMock.rejectLogin(err) → flow → 'denied' / 'expired' / 'cancelled' - * - * Coverage: - * - startLogin returns flow_id + verification URLs + status='pending' - * - getFlow returns the in-memory snapshot - * - resolveLogin → status='authenticated' - * - rejectLogin(DeviceCodeTimeoutError) → status='expired' - * - rejectLogin(OAuthError 'aborted') → status='cancelled' - * - rejectLogin(OAuthError 'denied') → status='denied' - * - rejectLogin(generic) → status='denied' with error_message preserved - * - cancelLogin on pending → status='cancelled', AbortController fired - * - cancelLogin on terminal → cancelled=false, status unchanged - * - startLogin while another is pending → previous flips to 'cancelled', - * new flow gets fresh flow_id - * - logout → delegates to facade.logout - */ - -import { describe, expect, it, vi } from 'vitest'; - -import { - DeviceCodeTimeoutError, - OAuthError, - type DeviceAuthorization, -} from '@pythoughts/pythinker-code-oauth'; - -import type { ServicesAuthFacade } from '../../src/services/auth/managedAuth'; -import { IEnvironmentService } from '../../src/services/environment/environment'; -import { OAuthService } from '../../src/services/oauth/oauthService'; - -interface LoginCall { - providerName: string | undefined; - onDeviceCode: ((auth: DeviceAuthorization) => void | Promise) | undefined; - signal: AbortSignal | undefined; - resolve: (value: { providerName: string; ok: true }) => void; - reject: (reason: unknown) => void; - promise: Promise; -} - -interface MockFacade { - facade: ServicesAuthFacade; - loginCalls: LoginCall[]; - logoutCalls: Array<{ providerName: string | undefined }>; -} - -function makeMockFacade(): MockFacade { - const loginCalls: LoginCall[] = []; - const logoutCalls: Array<{ providerName: string | undefined }> = []; - - const facade = { - login: vi.fn((providerName: string | undefined, options: { - onDeviceCode?: (auth: DeviceAuthorization) => void | Promise; - signal?: AbortSignal; - }) => { - let resolveFn!: (v: { providerName: string; ok: true }) => void; - let rejectFn!: (r: unknown) => void; - const promise = new Promise<{ providerName: string; ok: true }>((resolve, reject) => { - resolveFn = resolve; - rejectFn = reject; - }); - loginCalls.push({ - providerName, - onDeviceCode: options.onDeviceCode, - signal: options.signal, - resolve: resolveFn, - reject: rejectFn, - promise, - }); - return promise; - }), - logout: vi.fn(async (providerName: string | undefined) => { - logoutCalls.push({ providerName }); - return { providerName: providerName ?? 'managed:kimi-code', ok: true as const }; - }), - } as unknown as ServicesAuthFacade; - - return { facade, loginCalls, logoutCalls }; -} - -function fakeDeviceAuth(overrides: Partial = {}): DeviceAuthorization { - return { - deviceCode: 'dev-code-secret', - userCode: 'PYTH-1234', - verificationUri: 'https://example.com/device', - verificationUriComplete: 'https://example.com/device?user_code=PYTH-1234', - expiresIn: 900, - interval: 5, - ...overrides, - }; -} - -async function flushMicrotasks(): Promise { - // Two ticks is enough to settle the .then / .catch chain inside - // OAuthService.startLogin. - await Promise.resolve(); - await Promise.resolve(); -} - -function makeImpl(): { impl: OAuthService; mock: MockFacade } { - const mock = makeMockFacade(); - const env: IEnvironmentService = { - _serviceBrand: undefined, - homeDir: '/tmp/oauth-test', - configPath: '/tmp/oauth-test/config.toml', - }; - const impl = OAuthService._createForTest(env, mock.facade); - return { impl, mock }; -} - -describe('OAuthService.startLogin', () => { - it('returns flow_id + verification URLs once the facade fires onDeviceCode', async () => { - const { impl, mock } = makeImpl(); - - const startPromise = impl.startLogin(); - await flushMicrotasks(); - expect(mock.loginCalls).toHaveLength(1); - - // Fire the device-code callback from the facade side. - const auth = fakeDeviceAuth(); - await mock.loginCalls[0]!.onDeviceCode?.(auth); - - const start = await startPromise; - expect(start.status).toBe('pending'); - expect(start.flow_id).toMatch(/^oauth_/); - expect(start.verification_uri).toBe(auth.verificationUri); - expect(start.verification_uri_complete).toBe(auth.verificationUriComplete); - expect(start.user_code).toBe(auth.userCode); - expect(start.expires_in).toBe(900); - expect(start.interval).toBe(5); - expect(start.provider).toBe('managed:kimi-code'); - }); - - it('falls back to 15-min expires_in when the OAuth host omits the field', async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.( - fakeDeviceAuth({ expiresIn: null }), - ); - const start = await startPromise; - expect(start.expires_in).toBe(15 * 60); - }); -}); - -describe('OAuthService.getFlow', () => { - it('returns undefined before any flow is started', () => { - const { impl } = makeImpl(); - expect(impl.getFlow()).toBeUndefined(); - }); - - it('returns the pending snapshot after start', async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - const start = await startPromise; - - const snap = impl.getFlow(); - expect(snap).toBeDefined(); - expect(snap!.flow_id).toBe(start.flow_id); - expect(snap!.status).toBe('pending'); - expect(snap!.resolved_at).toBeUndefined(); - expect(snap!.error_message).toBeUndefined(); - }); - - it("does NOT leak device_code via the snapshot", async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - const snap = impl.getFlow(); - expect(JSON.stringify(snap)).not.toContain('dev-code-secret'); - }); -}); - -describe('OAuthService — terminal transitions', () => { - it("'authenticated' on facade.login resolve", async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - - mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); - await flushMicrotasks(); - - expect(impl.getFlow()!.status).toBe('authenticated'); - expect(impl.getFlow()!.resolved_at).toBeDefined(); - }); - - it("'expired' on DeviceCodeTimeoutError", async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - - mock.loginCalls[0]!.reject(new DeviceCodeTimeoutError('timed out')); - await flushMicrotasks(); - - expect(impl.getFlow()!.status).toBe('expired'); - expect(impl.getFlow()!.error_message).toBe('timed out'); - }); - - it("'denied' on OAuthError carrying 'denied'", async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - - mock.loginCalls[0]!.reject(new OAuthError('Authorization denied')); - await flushMicrotasks(); - - expect(impl.getFlow()!.status).toBe('denied'); - }); - - it("'cancelled' on OAuthError carrying 'aborted'", async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - - mock.loginCalls[0]!.reject(new OAuthError('Login aborted by caller')); - await flushMicrotasks(); - - expect(impl.getFlow()!.status).toBe('cancelled'); - }); - - it("'denied' for generic failures, preserving error_message", async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - - mock.loginCalls[0]!.reject(new Error('ECONNREFUSED')); - await flushMicrotasks(); - - const snap = impl.getFlow()!; - expect(snap.status).toBe('denied'); - expect(snap.error_message).toBe('ECONNREFUSED'); - }); -}); - -describe('OAuthService.cancelLogin', () => { - it('cancels a pending flow and fires the AbortController', async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - - const aborted = new Promise((resolve) => { - mock.loginCalls[0]!.signal!.addEventListener('abort', () => resolve(true)); - }); - - const result = await impl.cancelLogin(); - expect(result).toEqual({ cancelled: true, status: 'cancelled' }); - expect(await aborted).toBe(true); - expect(impl.getFlow()!.status).toBe('cancelled'); - }); - - it('idempotently reports the current status on terminal flows', async () => { - const { impl, mock } = makeImpl(); - const startPromise = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await startPromise; - mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); - await flushMicrotasks(); - - const result = await impl.cancelLogin(); - expect(result).toEqual({ cancelled: false, status: 'authenticated' }); - }); - - it('returns cancelled=false when no flow has ever been started', async () => { - const { impl } = makeImpl(); - const result = await impl.cancelLogin(); - expect(result).toEqual({ cancelled: false, status: 'cancelled' }); - }); -}); - -describe('OAuthService — supersede (PLAN D6.4)', () => { - it("flips the previous pending flow to 'cancelled' and mints a new flow_id", async () => { - const { impl, mock } = makeImpl(); - - const first = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - const firstStart = await first; - - const second = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[1]!.onDeviceCode?.( - fakeDeviceAuth({ deviceCode: 'second-secret', userCode: 'PYTH-9999' }), - ); - const secondStart = await second; - - expect(secondStart.flow_id).not.toBe(firstStart.flow_id); - expect(impl.getFlow()!.flow_id).toBe(secondStart.flow_id); - expect(impl.getFlow()!.status).toBe('pending'); - expect(mock.loginCalls[0]!.signal!.aborted).toBe(true); - }); -}); - -describe('OAuthService.logout', () => { - it('delegates to facade.logout and returns logged_out=true', async () => { - const { impl, mock } = makeImpl(); - const result = await impl.logout(); - expect(result).toEqual({ logged_out: true, provider: 'managed:kimi-code' }); - expect(mock.logoutCalls).toHaveLength(1); - }); - - it('also cancels any pending flow', async () => { - const { impl, mock } = makeImpl(); - const start = impl.startLogin(); - await flushMicrotasks(); - await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); - await start; - - await impl.logout(); - // After logout, the in-memory flow is in 'cancelled' terminal state - expect(impl.getFlow()!.status).toBe('cancelled'); - expect(mock.loginCalls[0]!.signal!.aborted).toBe(true); - }); -}); diff --git a/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts b/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts deleted file mode 100644 index fe280ed6..00000000 --- a/packages/node-sdk/examples/pythinker-harness-auth-smoke.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { createPythinkerHarness, type PythinkerHarness } from '@pythoughts/pythinker-code-sdk'; - -import { smokeIdentityFromEnv, runPromptToEnd } from './runtime-smoke-helpers'; - -const MANAGED_KIMI_CODE_PROVIDER = 'managed:kimi-code'; - -async function main(): Promise { - const explicitHomeDir = process.env['PYTHINKER_SDK_AUTH_SMOKE_HOME']; - const explicitWorkDir = process.env['PYTHINKER_SDK_AUTH_SMOKE_WORK_DIR']; - const homeDir = explicitHomeDir ?? (await mkdtemp(join(tmpdir(), 'pythinker-sdk-auth-smoke-home-'))); - const workDir = explicitWorkDir ?? (await mkdtemp(join(tmpdir(), 'pythinker-sdk-auth-smoke-work-'))); - const keepToken = shouldKeepToken(explicitHomeDir !== undefined); - const forceLogin = process.env['PYTHINKER_SDK_AUTH_SMOKE_FORCE_LOGIN'] === '1'; - const prompt = - process.env['PYTHINKER_SDK_AUTH_SMOKE_PROMPT'] ?? 'Reply with exactly: Pythinker SDK auth smoke ok'; - const harness = createPythinkerHarness({ homeDir, identity: smokeIdentityFromEnv() }); - - process.stdout.write(`home: ${homeDir}\n`); - process.stdout.write(`workDir: ${workDir}\n`); - - try { - if (forceLogin) { - await harness.auth.logout(MANAGED_KIMI_CODE_PROVIDER); - process.stdout.write('cleared existing smoke token\n'); - } - - const login = await harness.auth.login(undefined, { onDeviceCode: printDeviceCode }); - const config = await harness.getConfig({ reload: true }); - const status = await harness.auth.status(MANAGED_KIMI_CODE_PROVIDER); - const usage = await harness.auth.getManagedUsage(MANAGED_KIMI_CODE_PROVIDER); - - if (login.defaultModel === undefined || config.defaultModel === undefined) { - throw new Error('login did not provision a default model'); - } - if (status.providers[0]?.hasToken !== true) { - throw new Error('status did not report a stored token after login'); - } - if (config.providers[MANAGED_KIMI_CODE_PROVIDER]?.oauth?.key !== 'oauth/kimi-code') { - throw new Error('managed provider oauth config was not written'); - } - - process.stdout.write(`provider: ${login.providerName}\n`); - process.stdout.write(`default model: ${config.defaultModel}\n`); - printUsage(usage); - - const session = await harness.createSession({ - workDir, - model: config.defaultModel, - }); - const ended = await runPromptToEnd(session, prompt); - if (ended.type !== 'turn.ended' || ended.reason !== 'completed') { - throw new Error(`Expected completed turn, got ${ended.type}`); - } - - process.stdout.write(`auth smoke passed: ${session.id}\n`); - } finally { - if (!keepToken) { - await harness.auth.logout(MANAGED_KIMI_CODE_PROVIDER).catch(() => {}); - } - await harness.close(); - if (explicitHomeDir === undefined && !keepToken) { - await rm(homeDir, { recursive: true, force: true }); - } - if (explicitWorkDir === undefined) { - await rm(workDir, { recursive: true, force: true }); - } - } -} - -function printDeviceCode(auth: { - readonly userCode: string; - readonly verificationUri: string; - readonly verificationUriComplete: string; - readonly expiresIn: number | null; -}): void { - process.stdout.write( - [ - 'Complete Pythinker OAuth device login:', - ` URL: ${auth.verificationUriComplete || auth.verificationUri}`, - ` Code: ${auth.userCode}`, - auth.expiresIn === null ? undefined : ` Expires in: ${String(auth.expiresIn)}s`, - '', - ] - .filter((line): line is string => line !== undefined) - .join('\n'), - ); -} - -function printUsage(usage: Awaited>): void { - if (usage.kind === 'error') { - process.stderr.write(`usage request returned: ${usage.message}\n`); - return; - } - const summary = usage.summary; - if (summary === null) { - process.stdout.write(`usage: no summary, limits=${String(usage.limits.length)}\n`); - return; - } - process.stdout.write( - `usage: ${summary.label} ${String(summary.used)}/${String(summary.limit)}\n`, - ); -} - -function shouldKeepToken(hasExplicitHomeDir: boolean): boolean { - const value = process.env['PYTHINKER_SDK_AUTH_SMOKE_KEEP_TOKEN']; - if (value !== undefined) return value === '1' || value === 'true'; - return hasExplicitHomeDir; -} - -try { - await main(); -} catch (error: unknown) { - console.error(error); - process.exitCode = 1; -} diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts deleted file mode 100644 index baebb229..00000000 --- a/packages/node-sdk/src/auth.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - loadRuntimeConfigSafe, - readConfigFile, - readConfigFileForUpdate, - writeConfigFile, - type PythinkerConfig, - type OAuthRef, -} from '@pythoughts/agent-core'; -import { - applyManagedKimiCodeConfig, - applyManagedKimiCodeLogoutConfig, - KIMI_CODE_PROVIDER_NAME, - PythinkerOAuthToolkit, - resolveKimiCodeLoginAuth, - resolveKimiCodeRuntimeAuth, - type AuthManagedUsageResult, - type AuthStatus, - type BearerTokenProvider, - type FetchSubmitFeedbackResult, - type PythinkerHostIdentity, - type PythinkerOAuthLoginOptions, - type ManagedKimiConfigShape, - type OAuthRefreshOutcome, -} from '@pythoughts/pythinker-code-oauth'; - -import { mapOAuthTokenError } from '#/oauth-error'; - -export interface PythinkerAuthSubmitFeedbackInput { - readonly content: string; - readonly sessionId: string; - readonly version: string; - readonly os: string; - readonly model: string | null; -} - -export type PythinkerAuthLoginOptions = Omit; - -export interface PythinkerAuthLoginResult { - readonly providerName: string; - readonly ok: true; - readonly defaultModel: string; - readonly defaultThinking: boolean; - readonly configPath?: string | undefined; -} - -export interface PythinkerAuthLogoutResult { - readonly providerName: string; - readonly ok: true; -} - -export interface PythinkerAuthFacadeOptions { - readonly homeDir: string; - readonly configPath: string; - readonly identity?: PythinkerHostIdentity | undefined; - readonly onConfigUpdated?: ((config: PythinkerConfig) => void) | undefined; - readonly onRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; -} - -type SDKManagedConfig = PythinkerConfig & ManagedKimiConfigShape; - -export class PythinkerAuthFacade { - private readonly toolkit: PythinkerOAuthToolkit; - - constructor(private readonly options: PythinkerAuthFacadeOptions) { - this.toolkit = new PythinkerOAuthToolkit({ - homeDir: options.homeDir, - identity: options.identity, - onRefresh: options.onRefresh, - configAdapter: { - configPath: options.configPath, - // Write-path base read: strict (a salvaged base would drop the user's - // broken-but-fixable sections on rewrite) with an actionable message. - read: () => readConfigFileForUpdate(options.configPath) as SDKManagedConfig, - write: async (config) => { - await writeConfigFile(options.configPath, config); - }, - apply: applyManagedKimiCodeConfig, - remove: applyManagedKimiCodeLogoutConfig, - }, - }); - } - - async status(providerName?: string | undefined): Promise { - return this.toolkit.status(providerName, this.resolveRuntimeManagedAuth(providerName).oauthRef); - } - - async login( - providerName: string | undefined = KIMI_CODE_PROVIDER_NAME, - options: PythinkerAuthLoginOptions = {}, - ): Promise { - const auth = this.resolveManagedAuth(providerName); - const loginAuth = resolveKimiCodeLoginAuth({ - configuredBaseUrl: auth.baseUrl, - configuredOAuthRef: auth.oauthRef, - requestedBaseUrl: options.baseUrl, - requestedOAuthHost: options.oauthHost, - }); - const result = await this.toolkit.login(providerName, { - ...options, - baseUrl: loginAuth.baseUrl, - oauthHost: loginAuth.oauthHost, - oauthRef: options.oauthRef ?? loginAuth.oauthRef, - provisionConfig: true, - }); - if (result.provision === undefined) { - throw new Error('Pythinker auth login did not provision model config.'); - } - const updated = readConfigFile(this.options.configPath); - this.options.onConfigUpdated?.(updated); - return { - providerName: result.providerName, - ok: true, - defaultModel: result.provision.defaultModel, - defaultThinking: result.provision.defaultThinking, - configPath: result.provision.configPath, - }; - } - - async logout(providerName?: string | undefined): Promise { - const result = await this.toolkit.logout( - providerName, - this.resolveRuntimeManagedAuth(providerName).oauthRef, - ); - const updated = readConfigFile(this.options.configPath); - this.options.onConfigUpdated?.(updated); - return { - providerName: result.providerName, - ok: result.ok, - }; - } - - async getManagedUsage(providerName?: string | undefined): Promise { - const auth = this.resolveRuntimeManagedAuth(providerName); - return this.toolkit.getManagedUsage(providerName, { - oauthRef: auth.oauthRef, - baseUrl: auth.baseUrl, - }); - } - - async submitFeedback( - input: PythinkerAuthSubmitFeedbackInput, - providerName?: string | undefined, - ): Promise { - const auth = this.resolveRuntimeManagedAuth(providerName); - return this.toolkit.submitFeedback( - { - session_id: input.sessionId, - content: input.content, - version: input.version, - os: input.os, - model: input.model, - }, - providerName, - { - oauthRef: auth.oauthRef, - baseUrl: auth.baseUrl, - }, - ); - } - - async getCachedAccessToken( - providerName?: string, - oauthRef?: OAuthRef | undefined, - ): Promise { - return this.toolkit.getCachedAccessToken( - providerName, - this.runtimeOAuthRef(providerName, oauthRef), - ); - } - - readonly resolveOAuthTokenProvider = ( - providerName: string, - oauthRef?: OAuthRef | undefined, - ): BearerTokenProvider => { - const provider = this.toolkit.tokenProvider( - providerName, - this.runtimeOAuthRef(providerName, oauthRef), - ); - return { - getAccessToken: async (options) => { - try { - return await provider.getAccessToken(options); - } catch (error) { - // Classify OAuth token failures into the public PythinkerError protocol; - // unrecognized errors are rethrown raw (see mapOAuthTokenError). - throw mapOAuthTokenError(error, providerName) ?? error; - } - }, - }; - }; - - private resolveManagedAuth(providerName?: string | undefined): { - readonly oauthRef?: OAuthRef | undefined; - readonly baseUrl?: string | undefined; - } { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - // Read path: token/status resolution must work off a degraded config - // instead of failing the session when an unrelated section is broken. - // Write paths (the toolkit's configAdapter.read) stay strict. - const config = loadRuntimeConfigSafe(this.options.configPath).config; - const provider = config.providers[name]; - return { - oauthRef: provider?.oauth, - baseUrl: provider?.baseUrl, - }; - } - - private resolveRuntimeManagedAuth(providerName?: string | undefined): { - readonly oauthRef: OAuthRef; - readonly baseUrl?: string | undefined; - } { - const auth = this.resolveManagedAuth(providerName); - return resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: auth.baseUrl, - configuredOAuthRef: auth.oauthRef, - }); - } - - private runtimeOAuthRef( - providerName: string | undefined, - oauthRef?: OAuthRef | undefined, - ): OAuthRef | undefined { - if ((providerName ?? KIMI_CODE_PROVIDER_NAME) !== KIMI_CODE_PROVIDER_NAME) return oauthRef; - const auth = this.resolveManagedAuth(providerName); - return resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: auth.baseUrl, - configuredOAuthRef: oauthRef ?? auth.oauthRef, - }).oauthRef; - } -} diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 71a3931e..2ba7f487 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -1,7 +1,6 @@ export { PythinkerHarness } from '#/pythinker-harness'; export type { PythinkerHarnessRuntimeOptions } from '#/pythinker-harness'; export { Session } from '#/session'; -export { PythinkerAuthFacade } from '#/auth'; export { createPythinkerHarness, SDKRpcClient, type SDKRpcClientOptions } from '#/sdk-rpc-client'; export { runPythinkerMcpServer, type PythinkerMcpServerOptions } from '#/mcp-server'; export { @@ -18,8 +17,6 @@ export { type SetSessionDynamicWorkflowModeRpcInput, type SetSessionFastModeRpcInput, } from '#/rpc'; -export { PythinkerForCodingProvider } from '#/pythinker-code-model-provider'; -export type { PythinkerForCodingProviderOptions } from '#/pythinker-code-model-provider'; export { applyCatalogProvider, @@ -126,11 +123,6 @@ export type { FlagSurface, } from '@pythoughts/agent-core'; -export type { - PythinkerAuthLoginResult, - PythinkerAuthLogoutResult, - PythinkerAuthSubmitFeedbackInput, -} from '#/auth'; export * from '#/events'; export type * from '#/types'; diff --git a/packages/node-sdk/src/login/flows.ts b/packages/node-sdk/src/login/flows.ts index d49db382..ff5e5e16 100644 --- a/packages/node-sdk/src/login/flows.ts +++ b/packages/node-sdk/src/login/flows.ts @@ -10,12 +10,10 @@ import { OpenAICodexApiError, OpenPlatformApiError, runOpenAICodexOAuthFlow, - type ManagedKimiCodeModelInfo, - type ManagedKimiConfigShape, - KIMI_CODE_PROVIDER_NAME as DEFAULT_OAUTH_PROVIDER_NAME, type OpenPlatformDefinition, + type PlatformConfigShape, + type PlatformModelInfo, } from '@pythoughts/pythinker-code-oauth'; -import { log } from '@pythoughts/agent-core'; import { applyCatalogProvider, catalogBaseUrl, @@ -27,9 +25,8 @@ import { } from '#/catalog'; import { formatErrorMessage } from '../error-format'; -import { KIMI_CODE_PLATFORM_ID } from './platform-options'; import { catalogProviderIdFromPlatformValue } from './platform-values'; -import type { LoginProgressSpinnerHandle, LoginUi } from './types'; +import type { LoginUi } from './types'; // --------------------------------------------------------------------------- // Login flows behind the LoginUi port (shared with non-TUI renderers) @@ -54,9 +51,6 @@ export async function runLogin(ui: LoginUi): Promise { return connectCatalogProvider(ui, catalogProviderId, catalog[catalogProviderId]); } - if (platformId === KIMI_CODE_PLATFORM_ID) { - return handlePythinkerCodeOAuthLogin(ui); - } if (platformId === OPENAI_CODEX_OAUTH_PLATFORM_ID) { return handleOpenAICodexOAuthLogin(ui); @@ -77,66 +71,6 @@ export async function runLogin(ui: LoginUi): Promise { return handleOpenPlatformLogin(ui, platform); } -async function handlePythinkerCodeOAuthLogin(ui: LoginUi): Promise { - const status = await ui.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); - const alreadyLoggedIn = status.providers.some( - (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, - ); - - let spinner: LoginProgressSpinnerHandle | undefined; - const controller = new AbortController(); - const cancelLogin = (): void => { - controller.abort(); - }; - ui.cancelInFlight = cancelLogin; - try { - await ui.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, { - signal: controller.signal, - onDeviceCode: (data) => { - spinner = ui.showLoginAuthorizationPrompt(data); - }, - }); - spinner?.stop({ ok: true, label: 'Logged in.' }); - spinner = undefined; - try { - await ui.refreshConfigAfterLogin(); - } catch (refreshError) { - const message = formatErrorMessage(refreshError); - ui.showError(`Authentication successful, but failed to refresh config: ${message}`); - return false; - } - ui.track('login', { - provider: DEFAULT_OAUTH_PROVIDER_NAME, - already_logged_in: alreadyLoggedIn, - }); - if (alreadyLoggedIn) { - ui.showStatus('Already logged in. Model configuration refreshed.'); - } - return true; - } catch (error) { - const cancelled = controller.signal.aborted; - spinner?.stop({ - ok: false, - label: cancelled ? 'Login cancelled.' : 'Login failed.', - }); - spinner = undefined; - if (cancelled) return false; - log.warn('login failed', { - providerName: DEFAULT_OAUTH_PROVIDER_NAME, - alreadyLoggedIn, - sessionId: ui.sessionId, - error, - }); - const message = formatErrorMessage(error); - ui.showError(`Login failed: ${message}`); - return false; - } finally { - if (ui.cancelInFlight === cancelLogin) { - ui.cancelInFlight = undefined; - } - } -} - async function handleOpenPlatformLogin( ui: LoginUi, platform: OpenPlatformDefinition, @@ -155,7 +89,7 @@ async function handleOpenPlatformLogin( }; ui.cancelInFlight = cancelLogin; - let models: ManagedKimiCodeModelInfo[]; + let models: PlatformModelInfo[]; try { models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); models = filterModelsByPrefix(models, platform); @@ -192,7 +126,7 @@ async function handleOpenPlatformLogin( } const config = await ui.harness.getConfig(); - applyOpenPlatformConfig(config as ManagedKimiConfigShape, { + applyOpenPlatformConfig(config as PlatformConfigShape, { platform, models, selectedModel: selection.model, @@ -360,7 +294,7 @@ async function handleOpenAICodexOAuthLogin(ui: LoginUi): Promise { return false; } - let models: ManagedKimiCodeModelInfo[]; + let models: PlatformModelInfo[]; try { models = await fetchOpenAICodexModels({ accessToken: tokens.accessToken, @@ -402,7 +336,7 @@ async function handleOpenAICodexOAuthLogin(ui: LoginUi): Promise { } const config = await ui.harness.getConfig(); - applyOpenAICodexOAuthConfig(config as ManagedKimiConfigShape, { + applyOpenAICodexOAuthConfig(config as PlatformConfigShape, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, accountId: tokens.accountId, diff --git a/packages/node-sdk/src/login/model-alias.ts b/packages/node-sdk/src/login/model-alias.ts index c92be162..cc63dbde 100644 --- a/packages/node-sdk/src/login/model-alias.ts +++ b/packages/node-sdk/src/login/model-alias.ts @@ -1,7 +1,7 @@ import type { ModelAlias } from '@pythoughts/agent-core'; import { capabilitiesForModel, - type ManagedKimiCodeModelInfo, + type PlatformModelInfo, } from '@pythoughts/pythinker-code-oauth'; /** @@ -13,7 +13,7 @@ import { */ export function managedModelToAlias( platformId: string, - model: ManagedKimiCodeModelInfo, + model: PlatformModelInfo, ): ModelAlias { return { provider: platformId, diff --git a/packages/node-sdk/src/login/types.ts b/packages/node-sdk/src/login/types.ts index ac5f8edb..515e3bbe 100644 --- a/packages/node-sdk/src/login/types.ts +++ b/packages/node-sdk/src/login/types.ts @@ -1,7 +1,6 @@ import type { - DeviceAuthorization, - ManagedKimiCodeModelInfo, OpenPlatformDefinition, + PlatformModelInfo, } from '@pythoughts/pythinker-code-oauth'; import type { Catalog, CatalogModel } from '#/catalog'; import type { PythinkerHarness } from '#/pythinker-harness'; @@ -34,7 +33,6 @@ export interface LoginUi { showStatus(message: string): void; showError(message: string): void; showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; - showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; promptPlatformSelection(): Promise; promptApiKey( platformName: string, @@ -42,9 +40,9 @@ export interface LoginUi { options?: ApiKeyPromptOptions, ): Promise; promptModelSelectionForOpenPlatform( - models: ManagedKimiCodeModelInfo[], + models: PlatformModelInfo[], platform: OpenPlatformDefinition, - ): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined>; + ): Promise<{ model: PlatformModelInfo; effort: string } | undefined>; promptModelSelectionForCatalog( providerId: string, models: CatalogModel[], diff --git a/packages/node-sdk/src/mcp-server.ts b/packages/node-sdk/src/mcp-server.ts index e8588591..3dd2ea89 100644 --- a/packages/node-sdk/src/mcp-server.ts +++ b/packages/node-sdk/src/mcp-server.ts @@ -23,7 +23,6 @@ export async function runPythinkerMcpServer( configPath: options.configPath, skillDirs: options.skillDirs, telemetry: options.telemetry, - onOAuthRefresh: options.onOAuthRefresh, }); const summary = await rpc.createSession({ workDir: options.workDir, diff --git a/packages/node-sdk/src/oauth-error.ts b/packages/node-sdk/src/oauth-error.ts deleted file mode 100644 index eb1776fa..00000000 --- a/packages/node-sdk/src/oauth-error.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ErrorCodes, PythinkerError } from '@pythoughts/agent-core'; -import { - OAuthConnectionError, - OAuthUnauthorizedError, - RetryableRefreshError, -} from '@pythoughts/pythinker-code-oauth'; - -/** - * Classify an OAuth token-fetch failure into the public {@link PythinkerError} - * protocol so callers (turn serialization, SDK clients, ACP) can react on - * `code` rather than on class identity. - * - * Only errors we can positively identify are mapped: - * - `OAuthUnauthorizedError` → `auth.login_required` (drive the user through - * `/login`). - * - `OAuthConnectionError` / `RetryableRefreshError` → - * `provider.connection_error` (transient; the user can retry). - * - * Anything else returns `undefined` so the caller rethrows it raw and lets it - * surface as `internal` with the original message preserved. We deliberately do - * **not** guess a category for unrecognized errors — masking e.g. a storage or - * lock failure as `auth.login_required` would send the user down the wrong - * remediation path. - */ -export function mapOAuthTokenError(error: unknown, providerName: string): PythinkerError | undefined { - if (error instanceof OAuthUnauthorizedError) { - return new PythinkerError( - ErrorCodes.AUTH_LOGIN_REQUIRED, - `OAuth provider "${providerName}" requires login before it can be used.`, - { cause: error }, - ); - } - if (error instanceof OAuthConnectionError || error instanceof RetryableRefreshError) { - return new PythinkerError( - ErrorCodes.PROVIDER_CONNECTION_ERROR, - `OAuth provider "${providerName}" failed to fetch an access token: ${error.message}`, - { cause: error }, - ); - } - return undefined; -} diff --git a/packages/node-sdk/src/pythinker-code-model-provider.ts b/packages/node-sdk/src/pythinker-code-model-provider.ts deleted file mode 100644 index 91bf40e4..00000000 --- a/packages/node-sdk/src/pythinker-code-model-provider.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - ErrorCodes, - PythinkerError, - resolvePythinkerHome, - type Logger, - type ModelProvider, - type ResolvedRuntimeProvider, -} from '@pythoughts/agent-core'; -import { - createPythinkerDefaultHeaders, - KIMI_CODE_FLOW_CONFIG, - KIMI_CODE_PROVIDER_NAME, - PythinkerOAuthToolkit, - kimiCodeBaseUrl, - resolveKimiCodeOAuthRef, - type PythinkerHostIdentity, - type ManagedKimiOAuthRef, -} from '@pythoughts/pythinker-code-oauth'; -import type { - ProviderConfig as KosongProviderConfig, - ProviderRequestAuth, -} from '@pythoughts/kosong'; -import { APIStatusError, UNKNOWN_CAPABILITY } from '@pythoughts/kosong'; - -import { mapOAuthTokenError } from '#/oauth-error'; - -export interface PythinkerForCodingProviderOptions extends PythinkerHostIdentity { - readonly homeDir?: string; - readonly model?: string; - readonly baseUrl?: string; - readonly promptCacheKey?: string; - readonly defaultHeaders?: Record; -} - -export class PythinkerForCodingProvider implements ModelProvider { - private readonly model: string; - private readonly baseUrl: string; - private readonly promptCacheKey: string | undefined; - private readonly defaultHeaders: Record | undefined; - private readonly toolkit: PythinkerOAuthToolkit; - private readonly homeDir: string; - private readonly identity: PythinkerHostIdentity; - private readonly oauthRef: ManagedKimiOAuthRef; - - constructor(options: PythinkerForCodingProviderOptions) { - this.model = options.model ?? 'pythinker-for-coding'; - this.baseUrl = options.baseUrl ?? kimiCodeBaseUrl(); - this.promptCacheKey = options.promptCacheKey; - this.defaultHeaders = options.defaultHeaders; - this.homeDir = resolvePythinkerHome(options.homeDir); - this.identity = { - userAgentProduct: options.userAgentProduct, - version: options.version, - userAgentSuffix: options.userAgentSuffix, - }; - this.oauthRef = resolveKimiCodeOAuthRef({ - oauthHost: KIMI_CODE_FLOW_CONFIG.oauthHost, - baseUrl: this.baseUrl, - }); - this.toolkit = new PythinkerOAuthToolkit({ - homeDir: this.homeDir, - identity: this.identity, - }); - } - - get defaultModel(): string { - return this.model; - } - - resolveProviderConfig(model: string): ResolvedRuntimeProvider { - if (model !== this.model) { - throw new PythinkerError( - ErrorCodes.CONFIG_INVALID, - `Model "${model}" is not supported by PythinkerForCodingProvider.`, - ); - } - - const provider: KosongProviderConfig = { - type: 'pythinker', - model: this.model, - baseUrl: this.baseUrl, - generationKwargs: this.promptCacheKey - ? { prompt_cache_key: this.promptCacheKey } - : undefined, - defaultHeaders: { - ...createPythinkerDefaultHeaders({ - homeDir: this.homeDir, - ...this.identity, - }), - ...this.defaultHeaders, - }, - }; - - return { - providerName: 'pythinker-for-coding', - provider, - modelCapabilities: UNKNOWN_CAPABILITY, - }; - } - - resolveAuth(_model: string, _options?: { readonly log?: Logger }) { - return async (request: (auth: ProviderRequestAuth) => Promise): Promise => { - let auth = await this.buildAuth(false); - for (let refreshed = false; ; refreshed = true) { - try { - return await request(auth); - } catch (error) { - const is401 = error instanceof APIStatusError && error.statusCode === 401; - if (!is401) throw error; - if (refreshed) { - throw new PythinkerError( - ErrorCodes.AUTH_LOGIN_REQUIRED, - 'OAuth token was rejected after refresh. Run /login to re-authenticate.', - { cause: error }, - ); - } - auth = await this.buildAuth(true); - } - } - }; - } - - private async buildAuth(force: boolean): Promise { - try { - const apiKey = await this.toolkit.ensureFresh(KIMI_CODE_PROVIDER_NAME, { - force, - oauthRef: this.oauthRef, - }); - return { apiKey }; - } catch (error) { - // Classify OAuth token failures into the public PythinkerError protocol so the - // turn surfaces `auth.login_required` / `provider.connection_error` - // instead of collapsing everything to `internal`. Unrecognized errors are - // rethrown raw (see mapOAuthTokenError). - throw mapOAuthTokenError(error, KIMI_CODE_PROVIDER_NAME) ?? error; - } - } -} diff --git a/packages/node-sdk/src/pythinker-harness.ts b/packages/node-sdk/src/pythinker-harness.ts index b0087f1a..631b52ee 100644 --- a/packages/node-sdk/src/pythinker-harness.ts +++ b/packages/node-sdk/src/pythinker-harness.ts @@ -2,12 +2,12 @@ import type { Kaos } from '@pythoughts/kaos'; import { ErrorCodes, PythinkerError, + resolveProviderApiKey, withTelemetryContext, type ExperimentalFeatureState, } from '@pythoughts/agent-core'; import { Session } from '#/session'; -import type { PythinkerAuthFacade } from '#/auth'; import type { SDKRpcClientBase } from '#/rpc'; import type { AgentProfileCatalog, @@ -36,7 +36,6 @@ export interface PythinkerHarnessRuntimeOptions { readonly uiMode?: string; readonly homeDir: string; readonly configPath: string; - readonly auth: PythinkerAuthFacade; readonly telemetry: TelemetryClient; readonly ensureConfigFile: () => Promise; readonly onClose: () => void | Promise; @@ -45,7 +44,6 @@ export interface PythinkerHarnessRuntimeOptions { export class PythinkerHarness { readonly homeDir: string; readonly configPath: string; - readonly auth: PythinkerAuthFacade; private readonly identity: PythinkerHostIdentity | undefined; private readonly uiMode: string; @@ -63,7 +61,6 @@ export class PythinkerHarness { this.homeDir = options.homeDir; this.configPath = options.configPath; this.telemetry = options.telemetry; - this.auth = options.auth; this.ensureConfigFileImpl = options.ensureConfigFile; this.closeImpl = options.onClose; } @@ -219,6 +216,19 @@ export class PythinkerHarness { return this.rpc.getConfig(options); } + /** + * True when at least one configured provider resolves a usable credential. + * This is the whole of "is the user logged in" now that every login path — + * API key, catalog provider, OpenAI Codex OAuth — ends in a provider entry + * carrying an `apiKey` or an `apiKeyEnvVar`. + */ + async isAuthenticated(): Promise { + const config = await this.getConfig({ reload: true }); + return Object.values(config.providers ?? {}).some( + (provider) => resolveProviderApiKey(provider) !== undefined, + ); + } + /** Warnings from the most recent config.toml load; empty when the config is fully valid. */ async getConfigDiagnostics(): Promise { return this.rpc.getConfigDiagnostics(); diff --git a/packages/node-sdk/src/sdk-rpc-client.ts b/packages/node-sdk/src/sdk-rpc-client.ts index 3f2f0ffc..b4fa26e4 100644 --- a/packages/node-sdk/src/sdk-rpc-client.ts +++ b/packages/node-sdk/src/sdk-rpc-client.ts @@ -8,7 +8,6 @@ import { resolvePythinkerHome, resolveLoggingConfig, type CoreAPI, - type OAuthTokenProviderResolver, type RPCMethods, type SDKAPI, type TelemetryClient, @@ -16,14 +15,12 @@ import { import type { Kaos } from '@pythoughts/kaos'; import { assertPythinkerHostIdentity, createPythinkerDefaultHeaders } from '@pythoughts/pythinker-code-oauth'; -import { PythinkerAuthFacade } from '#/auth'; import { PythinkerHarness } from '#/pythinker-harness'; import { ClientAPI, SDKRpcClientBase } from '#/rpc'; import type { CreateSessionOptions, PythinkerHarnessOptions, PythinkerHostIdentity, - OAuthRefreshOutcome, ResumeSessionInput, ResumedSessionSummary, SessionSummary, @@ -33,10 +30,8 @@ export interface SDKRpcClientOptions { readonly homeDir?: string; readonly configPath?: string; readonly identity?: PythinkerHostIdentity; - readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver; readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient; - readonly onOAuthRefresh?: (outcome: OAuthRefreshOutcome) => void; } export class SDKRpcClient extends SDKRpcClientBase { @@ -44,7 +39,6 @@ export class SDKRpcClient extends SDKRpcClientBase { readonly configPath: string; readonly identity: PythinkerHostIdentity | undefined; readonly telemetry: TelemetryClient; - readonly auth: PythinkerAuthFacade; readonly core: PythinkerCore; private readonly ready: Promise>; @@ -59,12 +53,6 @@ export class SDKRpcClient extends SDKRpcClientBase { configPath: options.configPath, }); this.telemetry = options.telemetry ?? noopTelemetryClient; - this.auth = new PythinkerAuthFacade({ - homeDir: this.homeDir, - configPath: this.configPath, - identity: this.identity, - onRefresh: options.onOAuthRefresh, - }); void getRootLogger().configure(resolveLoggingConfig({ homeDir: this.homeDir })); @@ -73,8 +61,6 @@ export class SDKRpcClient extends SDKRpcClientBase { homeDir: options.homeDir, configPath: this.configPath, pythinkerRequestHeaders: this.createPythinkerRequestHeaders(), - resolveOAuthTokenProvider: - options.resolveOAuthTokenProvider ?? this.auth.resolveOAuthTokenProvider, skillDirs: options.skillDirs, telemetry: this.telemetry, appVersion: this.identity?.version, @@ -136,7 +122,6 @@ export function createPythinkerHarness(options: PythinkerHarnessOptions): Pythin uiMode: options.uiMode, homeDir: rpc.homeDir, configPath: rpc.configPath, - auth: rpc.auth, telemetry: rpc.telemetry, ensureConfigFile: () => rpc.ensureConfigFile(), onClose: () => rpc.close(), diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 5f4b1818..f372e203 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -11,7 +11,6 @@ import type { Kaos } from '@pythoughts/kaos'; import type { ContentPart, ModelCostRates } from '@pythoughts/kosong'; import type { PythinkerHostIdentity, - OAuthRefreshOutcome, } from '@pythoughts/pythinker-code-oauth'; export type JsonPrimitive = string | number | boolean | null; @@ -96,7 +95,7 @@ export type { WorkingTreeFileDiff, } from '@pythoughts/agent-core'; -export type { PythinkerHostIdentity, OAuthRefreshOutcome }; +export type { PythinkerHostIdentity }; export type { TelemetryClient, TelemetryContextPatch, TelemetryProperties }; export type { ContentPart, ModelCostRates, Role, ToolCall } from '@pythoughts/kosong'; @@ -124,7 +123,6 @@ export interface PythinkerHarnessOptions { readonly uiMode?: string; readonly skillDirs?: readonly string[]; readonly telemetry?: TelemetryClient | undefined; - readonly onOAuthRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; } export interface CreateSessionOptions { diff --git a/packages/node-sdk/test/auth-facade.test.ts b/packages/node-sdk/test/auth-facade.test.ts deleted file mode 100644 index 5a371363..00000000 --- a/packages/node-sdk/test/auth-facade.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - FileTokenStorage, - KIMI_CODE_PROVIDER_NAME, - PythinkerOAuthToolkit, - OAuthConnectionError, - OAuthError, - RetryableRefreshError, - resolveKimiCodeOAuthKey, - resolvePythinkerTokenStorageName, - type TokenInfo, -} from '@pythoughts/pythinker-code-oauth'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createPythinkerHarness, ErrorCodes, PythinkerError } from '#/index'; - -import { ProviderManager } from '../../agent-core/src/session/provider-manager'; -import { TEST_IDENTITY } from './test-identity'; - -let homeDir: string; - -type FetchMock = ( - input: Parameters[0], - init?: Parameters[1], -) => Promise; - -function fetchInputUrl(input: Parameters[0]): string { - if (typeof input === 'string') return input; - if (input instanceof URL) return input.href; - return input.url; -} - -function freshToken(): TokenInfo { - return { - accessToken: 'oauth-access-token', - refreshToken: 'oauth-refresh-token', - expiresAt: Math.floor(Date.now() / 1000) + 3600, - scope: '', - tokenType: 'Bearer', - expiresIn: 3600, - }; -} - -beforeEach(async () => { - homeDir = await mkdtemp(join(tmpdir(), 'pythinker-sdk-auth-')); -}); - -afterEach(async () => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - await rm(homeDir, { recursive: true, force: true }); -}); - -describe('PythinkerHarness.auth', () => { - it('can construct auth facade without host identity', () => { - expect(() => createPythinkerHarness({ homeDir })).not.toThrow(); - }); - - it('exposes a cached access token without refreshing auth state', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); - }); - - it('maps missing runtime OAuth tokens to login-required errors', async () => { - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect( - harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), - ).rejects.toMatchObject({ - code: ErrorCodes.AUTH_LOGIN_REQUIRED, - }); - }); - - it('maps transient OAuth token failures to provider connection errors', async () => { - const tokenErrors = [ - new OAuthConnectionError('OAuth request failed: fetch failed'), - new RetryableRefreshError('Token refresh failed (HTTP 503).'), - ]; - - for (const tokenError of tokenErrors) { - const tokenProviderSpy = vi - .spyOn(PythinkerOAuthToolkit.prototype, 'tokenProvider') - .mockReturnValue({ - async getAccessToken() { - throw tokenError; - }, - }); - try { - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - const error = await harness.auth - .resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME) - .getAccessToken() - .catch((error: unknown) => error); - - expect(error).toBeInstanceOf(PythinkerError); - expect(error).toMatchObject({ - code: ErrorCodes.PROVIDER_CONNECTION_ERROR, - message: expect.stringContaining(tokenError.message), - cause: tokenError, - }); - } finally { - tokenProviderSpy.mockRestore(); - } - } - }); - - it('preserves non-retryable OAuth refresh failures', async () => { - const oauthError = new OAuthError('bad client id'); - const tokenProviderSpy = vi - .spyOn(PythinkerOAuthToolkit.prototype, 'tokenProvider') - .mockReturnValue({ - async getAccessToken() { - throw oauthError; - }, - }); - try { - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect( - harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), - ).rejects.toBe(oauthError); - } finally { - tokenProviderSpy.mockRestore(); - } - }); - - it('resolves managed auth from a partially invalid config without throwing', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -api_key = "" - -[loop_control] -max_steps_per_turn = "abc" -`, - ); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - // Token resolution is a read path: a broken section elsewhere in - // config.toml must degrade, not break OAuth-backed sessions. - await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token'); - await expect(harness.auth.status()).resolves.toMatchObject({ - providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], - }); - }); - - it('resolves cached access tokens from the configured scoped OAuth ref', async () => { - const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - const storageName = resolvePythinkerTokenStorageName({ oauthKey }); - const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('kimi-code', freshToken()); - await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' }); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "https://api.dev.example.test/coding/v1" -api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } -`, - ); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.getCachedAccessToken()).resolves.toBe('dev-access-token'); - }); - - it('reports auth status from the configured scoped OAuth ref', async () => { - const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - await new FileTokenStorage(join(homeDir, 'credentials')).save( - resolvePythinkerTokenStorageName({ oauthKey }), - { ...freshToken(), accessToken: 'dev-access-token' }, - ); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "https://api.dev.example.test/coding/v1" -api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } -`, - ); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.status()).resolves.toEqual({ - providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], - }); - }); - - it('provisions SDK config using an existing Pythinker OAuth token', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - const fetchMock = vi.fn( - async (_input, _init) => - new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - supports_image_in: true, - supports_video_in: true, - display_name: 'Pythinker for Coding', - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - vi.stubGlobal('fetch', fetchMock); - - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - const result = await harness.auth.login(); - const config = await harness.getConfig({ reload: true }); - - expect(result).toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - defaultModel: 'kimi-code/pythinker-for-coding', - defaultThinking: true, - }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://api.kimi.com/coding/v1/models', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer oauth-access-token', - }), - }), - ); - expect(config.defaultModel).toBe('kimi-code/pythinker-for-coding'); - expect(config.models?.['kimi-code/pythinker-for-coding']).toMatchObject({ - capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], - displayName: 'Pythinker for Coding', - }); - expect(new ProviderManager({ config }).resolveProviderConfig(config.defaultModel!)).toMatchObject({ - modelCapabilities: { - tool_use: true, - }, - }); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - type: 'pythinker', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }); - expect(config.services?.pythoughtsSearch?.oauth).toEqual({ - storage: 'file', - key: 'oauth/kimi-code', - }); - }); - - it('logs in against the configured scoped OAuth host and base URL when env is absent', async () => { - const baseUrl = 'https://api.dev.example.test/coding/v1'; - const oauthHost = 'https://auth.dev.example.test'; - const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl }); - const storageName = resolvePythinkerTokenStorageName({ oauthKey }); - const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save(storageName, { - ...freshToken(), - accessToken: 'expired-dev-access-token', - refreshToken: 'dev-refresh-token', - expiresAt: 1, - }); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "${baseUrl}" -api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" } -`, - ); - let refreshBody: URLSearchParams | undefined; - let modelAuthorization: string | null | undefined; - const fetchMock = vi.fn(async (input, init) => { - const url = fetchInputUrl(input); - if (url === `${oauthHost}/api/oauth/token`) { - if (typeof init?.body !== 'string') throw new TypeError('expected form body'); - refreshBody = new URLSearchParams(init.body); - return new Response( - JSON.stringify({ - access_token: 'rotated-dev-access-token', - refresh_token: 'rotated-dev-refresh-token', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - if (url === `${baseUrl}/models`) { - modelAuthorization = new Headers(init?.headers).get('authorization'); - return new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - throw new Error(`unexpected request: ${url}`); - }); - vi.stubGlobal('fetch', fetchMock); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - defaultModel: 'kimi-code/pythinker-for-coding', - }); - expect(refreshBody?.get('grant_type')).toBe('refresh_token'); - expect(refreshBody?.get('refresh_token')).toBe('dev-refresh-token'); - expect(modelAuthorization).toBe('Bearer rotated-dev-access-token'); - await expect(storage.load(storageName)).resolves.toMatchObject({ - accessToken: 'rotated-dev-access-token', - }); - const config = await harness.getConfig({ reload: true }); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - baseUrl, - oauth: { storage: 'file', key: oauthKey, oauthHost }, - }); - expect(fetchMock.mock.calls.map((call) => fetchInputUrl(call[0]))).toEqual([ - `${oauthHost}/api/oauth/token`, - `${baseUrl}/models`, - ]); - }); - - it('recomputes legacy managed OAuth refs during login for non-default base URLs', async () => { - const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); - const scopedStorageName = resolvePythinkerTokenStorageName({ oauthKey }); - const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('kimi-code', { ...freshToken(), accessToken: 'legacy-access-token' }); - await storage.save(scopedStorageName, { - ...freshToken(), - accessToken: 'scoped-access-token', - }); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "${baseUrl}" -api_key = "" -oauth = { storage = "file", key = "oauth/kimi-code" } -`, - ); - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer scoped-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 harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - defaultModel: 'kimi-code/pythinker-for-coding', - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const config = await harness.getConfig({ reload: true }); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - baseUrl, - oauth: { storage: 'file', key: oauthKey, oauthHost: 'https://auth.kimi.com' }, - }); - }); - - it('logs in against environment OAuth host and base URL 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 envOauthKey = resolveKimiCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl }); - const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save(resolvePythinkerTokenStorageName({ oauthKey: configuredOauthKey }), { - ...freshToken(), - accessToken: 'configured-access-token', - }); - await storage.save(resolvePythinkerTokenStorageName({ oauthKey: envOauthKey }), { - ...freshToken(), - accessToken: 'env-access-token', - }); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "${configuredBaseUrl}" -api_key = "" -oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https://auth.pythinker.com" } -`, - ); - vi.stubEnv('PYTHINKER_CODE_BASE_URL', envBaseUrl); - vi.stubEnv('PYTHINKER_CODE_OAUTH_HOST', envOauthHost); - 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 harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.login()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - defaultModel: 'kimi-code/pythinker-for-coding', - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const config = await harness.getConfig({ reload: true }); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - baseUrl: envBaseUrl, - oauth: { storage: 'file', key: envOauthKey, oauthHost: envOauthHost }, - }); - }); - - it('starts degraded when a configured model alias does not have max_context_size', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - await writeFile( - join(homeDir, 'config.toml'), - ` -default_model = "pythinker-code/pythinker-for-coding" - -[providers."managed:kimi-code"] -type = "pythinker" -api_key = "" - -[models."pythinker-code/pythinker-for-coding"] -provider = "managed:kimi-code" -model = "pythinker-for-coding" -`, - ); - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - supports_image_in: true, - supports_video_in: true, - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ), - ); - - // A broken config must not prevent startup: the invalid model alias is - // dropped, the rest of the config survives, and a warning is reported. - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - const config = await harness.getConfig(); - expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeDefined(); - const { warnings } = await harness.getConfigDiagnostics(); - expect(warnings.some((w) => w.includes('models.pythinker-code/pythinker-for-coding'))).toBe(true); - }); - - it('removes managed Pythinker config on logout', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - await writeFile( - join(homeDir, 'config.toml'), - ` -default_model = "pythinker-code/pythinker-for-coding" - -[providers."managed:kimi-code"] -type = "pythinker" -api_key = "" -oauth = { storage = "file", key = "oauth/kimi-code" } - -[providers.custom] -type = "pythinker" -api_key = "sk-existing" - -[models."pythinker-code/pythinker-for-coding"] -provider = "managed:kimi-code" -model = "pythinker-for-coding" -max_context_size = 262144 - -[models.custom-default] -provider = "custom" -model = "custom-model" -max_context_size = 1000 - -[services.pythoughts_search] -base_url = "https://api.pythinker.com/coding/v1/search" -api_key = "" -oauth = { storage = "file", key = "oauth/kimi-code" } - -[services.pythoughts_fetch] -base_url = "https://api.pythinker.com/coding/v1/fetch" -api_key = "" -oauth = { storage = "file", key = "oauth/kimi-code" } -`, - ); - - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - }); - - const config = await harness.getConfig({ reload: true }); - expect(config.defaultModel).toBeUndefined(); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); - expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' }); - expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); - expect(config.models?.['custom-default']).toMatchObject({ provider: 'custom' }); - expect(config.services?.pythoughtsSearch).toBeUndefined(); - expect(config.services?.pythoughtsFetch).toBeUndefined(); - await expect( - new FileTokenStorage(join(homeDir, 'credentials')).load('kimi-code'), - ).resolves.toBeUndefined(); - - const text = await readFile(join(homeDir, 'config.toml'), 'utf-8'); - expect(text).not.toContain('managed:kimi-code'); - expect(text).not.toContain('kimi-code/pythinker-for-coding'); - expect(text).not.toContain('pythoughts_search'); - }); - - it('removes the configured scoped OAuth token on logout without touching the production token', async () => { - const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - const storageName = resolvePythinkerTokenStorageName({ oauthKey }); - const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('kimi-code', freshToken()); - await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' }); - await writeFile( - join(homeDir, 'config.toml'), - ` -default_model = "pythinker-code/pythinker-for-coding" - -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "https://api.dev.example.test/coding/v1" -api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } - -[models."pythinker-code/pythinker-for-coding"] -provider = "managed:kimi-code" -model = "pythinker-for-coding" -max_context_size = 262144 -`, - ); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - }); - - await expect(storage.load(storageName)).resolves.toBeUndefined(); - await expect(storage.load('kimi-code')).resolves.toMatchObject({ - accessToken: 'oauth-access-token', - }); - }); - - it('recomputes legacy managed OAuth refs during logout for non-default base URLs', async () => { - const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); - const scopedStorageName = resolvePythinkerTokenStorageName({ oauthKey }); - const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save('kimi-code', freshToken()); - await storage.save(scopedStorageName, { - ...freshToken(), - accessToken: 'scoped-access-token', - }); - await writeFile( - join(homeDir, 'config.toml'), - ` -default_model = "pythinker-code/pythinker-for-coding" - -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "${baseUrl}" -api_key = "" -oauth = { storage = "file", key = "oauth/kimi-code" } - -[models."pythinker-code/pythinker-for-coding"] -provider = "managed:kimi-code" -model = "pythinker-for-coding" -max_context_size = 262144 -`, - ); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - await expect(harness.auth.logout()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - }); - - await expect(storage.load(scopedStorageName)).resolves.toBeUndefined(); - await expect(storage.load('kimi-code')).resolves.toMatchObject({ - accessToken: 'oauth-access-token', - }); - }); - - it('gets managed usage without host identity and sends only auth headers', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - const fetchMock = vi.fn( - async (_input, _init) => - new Response( - JSON.stringify({ - usage: { used: 1, limit: 10, name: 'Weekly limit' }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - vi.stubGlobal('fetch', fetchMock); - - const harness = createPythinkerHarness({ homeDir }); - const result = await harness.auth.getManagedUsage(); - - expect(result).toMatchObject({ - kind: 'ok', - summary: { label: 'Weekly limit', used: 1, limit: 10 }, - }); - const init = fetchMock.mock.calls[0]?.[1] as RequestInit; - const headers = new Headers((init.headers ?? {}) as Record); - expect(headers.get('authorization')).toBe('Bearer oauth-access-token'); - expect(headers.get('accept')).toBe('application/json'); - expect(headers.get('user-agent')).toBeNull(); - expect(headers.get('x-msh-platform')).toBeNull(); - }); - - it('uses configured scoped OAuth refs and base URLs for managed usage and feedback', async () => { - const baseUrl = 'https://api.dev.example.test/coding/v1'; - const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl, - }); - const storageName = resolvePythinkerTokenStorageName({ oauthKey }); - await new FileTokenStorage(join(homeDir, 'credentials')).save(storageName, { - ...freshToken(), - accessToken: 'dev-access-token', - }); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "${baseUrl}" -api_key = "" -oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" } -`, - ); - const fetchMock = vi.fn(async (input) => { - const url = fetchInputUrl(input); - if (url.endsWith('/usages')) { - return new Response( - JSON.stringify({ usage: { used: 2, limit: 10, name: 'Dev limit' } }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - return new Response('', { status: 200 }); - }); - vi.stubGlobal('fetch', fetchMock); - const harness = createPythinkerHarness({ homeDir }); - - await expect(harness.auth.getManagedUsage()).resolves.toMatchObject({ - kind: 'ok', - summary: { label: 'Dev limit', used: 2, limit: 10 }, - }); - await expect( - harness.auth.submitFeedback({ - content: 'dev feedback', - sessionId: 'sess-dev', - version: 'pythinker-code-0.1.1', - os: 'Darwin 25.3.0', - model: 'kimi-code/pythinker-for-coding', - }), - ).resolves.toEqual({ kind: 'ok' }); - - expect(fetchMock.mock.calls[0]?.[0]).toBe(`${baseUrl}/usages`); - expect(fetchMock.mock.calls[1]?.[0]).toBe(`${baseUrl}/feedback`); - for (const call of fetchMock.mock.calls) { - const init = call[1]; - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer dev-access-token'); - } - }); - - it('uses environment managed endpoints for usage and feedback 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 envOauthKey = resolveKimiCodeOAuthKey({ - oauthHost: envOauthHost, - baseUrl: envBaseUrl, - }); - const storage = new FileTokenStorage(join(homeDir, 'credentials')); - await storage.save(resolvePythinkerTokenStorageName({ oauthKey: configuredOauthKey }), { - ...freshToken(), - accessToken: 'configured-access-token', - }); - await storage.save(resolvePythinkerTokenStorageName({ oauthKey: envOauthKey }), { - ...freshToken(), - accessToken: 'env-access-token', - }); - await writeFile( - join(homeDir, 'config.toml'), - ` -[providers."managed:kimi-code"] -type = "pythinker" -base_url = "${configuredBaseUrl}" -api_key = "" -oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https://auth.pythinker.com" } -`, - ); - vi.stubEnv('PYTHINKER_CODE_BASE_URL', envBaseUrl); - vi.stubEnv('PYTHINKER_CODE_OAUTH_HOST', envOauthHost); - const fetchMock = vi.fn(async (input) => { - const url = fetchInputUrl(input); - if (url.endsWith('/usages')) { - return new Response( - JSON.stringify({ usage: { used: 3, limit: 10, name: 'Env limit' } }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - return new Response('', { status: 200 }); - }); - vi.stubGlobal('fetch', fetchMock); - const harness = createPythinkerHarness({ homeDir }); - - await expect(harness.auth.status()).resolves.toEqual({ - providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], - }); - await expect(harness.auth.getCachedAccessToken()).resolves.toBe('env-access-token'); - await expect( - harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(), - ).resolves.toBe('env-access-token'); - await expect( - harness.auth - .resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME, { - storage: 'file', - key: configuredOauthKey, - oauthHost: 'https://auth.pythinker.com', - }) - .getAccessToken(), - ).resolves.toBe('env-access-token'); - await expect(harness.auth.getManagedUsage()).resolves.toMatchObject({ - kind: 'ok', - summary: { label: 'Env limit', used: 3, limit: 10 }, - }); - await expect( - harness.auth.submitFeedback({ - content: 'env feedback', - sessionId: 'sess-env', - version: 'pythinker-code-0.1.1', - os: 'Darwin 25.3.0', - model: 'kimi-code/pythinker-for-coding', - }), - ).resolves.toEqual({ kind: 'ok' }); - - expect(fetchMock.mock.calls[0]?.[0]).toBe(`${envBaseUrl}/usages`); - expect(fetchMock.mock.calls[1]?.[0]).toBe(`${envBaseUrl}/feedback`); - for (const call of fetchMock.mock.calls) { - expect(new Headers(call[1]?.headers).get('authorization')).toBe('Bearer env-access-token'); - } - }); - - it('submitFeedback maps camelCase input to snake_case body and posts with bearer auth', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - const fetchMock = vi.fn(async () => new Response('', { status: 200 })); - vi.stubGlobal('fetch', fetchMock); - - const harness = createPythinkerHarness({ homeDir }); - const result = await harness.auth.submitFeedback({ - content: 'great tool', - sessionId: 'sess-42', - version: 'pythinker-code-0.1.1', - os: 'Darwin 25.3.0', - model: 'kimi-code/pythinker-for-coding', - }); - - expect(result).toEqual({ kind: 'ok' }); - - const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; - const [url, init] = calls[0]!; - expect(url).toBe('https://api.kimi.com/coding/v1/feedback'); - expect(init?.method).toBe('POST'); - - const headers = new Headers((init?.headers ?? {}) as Record); - expect(headers.get('authorization')).toBe('Bearer oauth-access-token'); - expect(headers.get('content-type')).toBe('application/json'); - - expect(JSON.parse(init?.body as string)).toEqual({ - session_id: 'sess-42', - content: 'great tool', - version: 'pythinker-code-0.1.1', - os: 'Darwin 25.3.0', - model: 'kimi-code/pythinker-for-coding', - }); - }); - - it('submitFeedback surfaces HTTP errors without throwing', async () => { - await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken()); - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ message: 'feedback API rejected the request' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }), - ), - ); - - const harness = createPythinkerHarness({ homeDir }); - const result = await harness.auth.submitFeedback({ - content: 'x', - sessionId: 's', - version: 'pythinker-code-0.0.0', - os: 'Darwin 25.3.0', - model: null, - }); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBe(401); - expect(result.message).toBe('feedback API rejected the request'); - }); -}); diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index db1e9379..ae3d43eb 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -535,7 +535,6 @@ effort = "medium" const harness = new PythinkerHarness(rpc, { homeDir: '/tmp/home', configPath: '/tmp/config.toml', - auth: { status: async () => ({ providers: [] }) } as never, telemetry: recordingTelemetry(records), ensureConfigFile: async () => undefined, onClose: () => undefined, diff --git a/packages/node-sdk/test/pythinker-code-model-provider.test.ts b/packages/node-sdk/test/pythinker-code-model-provider.test.ts deleted file mode 100644 index 2386df4e..00000000 --- a/packages/node-sdk/test/pythinker-code-model-provider.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - PythinkerOAuthToolkit, - OAuthConnectionError, - OAuthError, - OAuthUnauthorizedError, - RetryableRefreshError, -} from '@pythoughts/pythinker-code-oauth'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { ErrorCodes, PythinkerError, PythinkerForCodingProvider } from '#/index'; - -import { TEST_IDENTITY } from './test-identity'; - -describe('PythinkerForCodingProvider OAuth error mapping', () => { - let homeDir: string; - - beforeEach(async () => { - homeDir = await mkdtemp(join(tmpdir(), 'pythinker-for-coding-provider-')); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - await rm(homeDir, { recursive: true, force: true }); - }); - - function resolveAuth() { - const provider = new PythinkerForCodingProvider({ homeDir, ...TEST_IDENTITY }); - return provider.resolveAuth('pythinker-for-coding'); - } - - it('maps unauthorized token failures to auth.login_required', async () => { - vi.spyOn(PythinkerOAuthToolkit.prototype, 'ensureFresh').mockRejectedValue( - new OAuthUnauthorizedError('No token for "pythinker-code". Run /login to authenticate.'), - ); - - const auth = resolveAuth(); - await expect(auth(async () => 'ok')).rejects.toMatchObject({ - code: ErrorCodes.AUTH_LOGIN_REQUIRED, - }); - }); - - it('maps transient token failures to provider.connection_error', async () => { - const tokenErrors = [ - new OAuthConnectionError('OAuth request to https://example.test failed: fetch failed'), - new RetryableRefreshError('Token refresh failed (HTTP 503).'), - ]; - - for (const tokenError of tokenErrors) { - vi.spyOn(PythinkerOAuthToolkit.prototype, 'ensureFresh').mockRejectedValue(tokenError); - - const auth = resolveAuth(); - const caught = await auth(async () => 'ok').catch((error: unknown) => error); - - expect(caught).toBeInstanceOf(PythinkerError); - expect(caught).toMatchObject({ - code: ErrorCodes.PROVIDER_CONNECTION_ERROR, - message: expect.stringContaining(tokenError.message), - cause: tokenError, - }); - - vi.restoreAllMocks(); - } - }); - - it('rethrows unrecognized OAuth errors raw instead of guessing a category', async () => { - const oauthError = new OAuthError('Token refresh failed (HTTP 400).'); - vi.spyOn(PythinkerOAuthToolkit.prototype, 'ensureFresh').mockRejectedValue(oauthError); - - const auth = resolveAuth(); - await expect(auth(async () => 'ok')).rejects.toBe(oauthError); - }); -}); diff --git a/packages/node-sdk/test/runtime-provider-oauth.test.ts b/packages/node-sdk/test/runtime-provider-oauth.test.ts deleted file mode 100644 index 6ae2af1a..00000000 --- a/packages/node-sdk/test/runtime-provider-oauth.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { ErrorCodes, PythinkerError, type PythinkerConfig, type Logger } from '#/index'; - -import { ProviderManager } from '../../agent-core/src/session/provider-manager'; - -function managedConfig(): PythinkerConfig { - return { - providers: { - 'managed:kimi-code': { - type: 'pythinker', - baseUrl: 'https://api.pythinker.com/coding/v1', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - }, - models: { - 'pythinker-code/pythinker-for-coding': { - provider: 'managed:kimi-code', - model: 'pythinker-for-coding', - maxContextSize: 262144, - }, - }, - defaultModel: 'pythinker-code/pythinker-for-coding', - }; -} - -async function resolveRuntimeProviderWithOAuth(options: { - readonly config: PythinkerConfig; - readonly resolveOAuthTokenProvider?: import('../../agent-core/src/session/provider-manager').OAuthTokenProviderResolver; - readonly log?: Logger; -}) { - const manager = new ProviderManager({ - config: options.config, - resolveOAuthTokenProvider: options.resolveOAuthTokenProvider, - }); - const model = options.config.defaultModel; - if (model === undefined) { - throw new PythinkerError(ErrorCodes.CONFIG_INVALID, 'No model is selected.'); - } - const { providerName, provider } = manager.resolveProviderConfig(model); - - const providerConfig = options.config.providers[providerName]; - if (providerConfig?.oauth !== undefined && (providerConfig.apiKey ?? '').length > 0) { - throw new PythinkerError( - ErrorCodes.CONFIG_INVALID, - `Provider "${providerName}" has both apiKey and oauth set in config.toml — they are mutually exclusive. Remove one.`, - ); - } - - const oauthRef = providerConfig?.oauth; - const tokenProvider = options.resolveOAuthTokenProvider?.(providerName, oauthRef); - - if (tokenProvider === undefined) { - throw new PythinkerError( - ErrorCodes.AUTH_LOGIN_REQUIRED, - `OAuth provider "${providerName}" requires login before it can be used.`, - ); - } - - // Replicate the old API's eager token fetch during resolution so - // test mocks see the expected call sequence. - try { - await tokenProvider.getAccessToken(undefined); - } catch (error) { - if ( - !(error instanceof PythinkerError && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) - ) { - options.log?.warn('oauth token fetch failed', { providerName, error }); - } - throw new PythinkerError( - ErrorCodes.AUTH_LOGIN_REQUIRED, - `OAuth provider "${providerName}" requires login before it can be used.`, - { cause: error }, - ); - } - - return { - providerName, - provider, - resolveAuth: async (opts?: { forceRefresh?: boolean }) => { - try { - const apiKey = await tokenProvider.getAccessToken( - opts?.forceRefresh ? { force: true } : undefined, - ); - if (apiKey.trim().length === 0) { - throw new PythinkerError( - ErrorCodes.AUTH_LOGIN_REQUIRED, - `OAuth provider "${providerName}" requires login before it can be used.`, - ); - } - return { apiKey }; - } catch (error) { - if ( - !(error instanceof PythinkerError && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) - ) { - options.log?.warn('oauth token fetch failed', { providerName, error }); - } - throw new PythinkerError( - ErrorCodes.AUTH_LOGIN_REQUIRED, - `OAuth provider "${providerName}" requires login before it can be used.`, - { cause: error }, - ); - } - }, - }; -} - -describe('resolveRuntimeProviderWithOAuth', () => { - it('returns request-scoped OAuth auth without storing the initial access token in provider config', async () => { - const tokens = ['initial-oauth-token', 'rotated-oauth-token', 'force-refreshed-oauth-token']; - const getAccessToken = vi.fn().mockImplementation(async () => { - const token = tokens.shift(); - if (token === undefined) throw new Error('unexpected token request'); - return token; - }); - - const resolved = await resolveRuntimeProviderWithOAuth({ - config: managedConfig(), - resolveOAuthTokenProvider: (_providerName, oauthRef) => { - expect(oauthRef).toEqual({ storage: 'file', key: 'oauth/kimi-code' }); - return { getAccessToken }; - }, - }); - - expect(resolved.providerName).toBe('managed:kimi-code'); - expect(resolved.provider).toMatchObject({ - type: 'pythinker', - model: 'pythinker-for-coding', - baseUrl: 'https://api.pythinker.com/coding/v1', - }); - expect(resolved.provider.apiKey).toBeUndefined(); - await expect(resolved.resolveAuth?.()).resolves.toEqual({ apiKey: 'rotated-oauth-token' }); - await expect(resolved.resolveAuth?.({ forceRefresh: true })).resolves.toEqual({ - apiKey: 'force-refreshed-oauth-token', - }); - expect(getAccessToken.mock.calls).toEqual([[undefined], [undefined], [{ force: true }]]); - }); - - it('throws a clear login-required error when no token provider exists', async () => { - await expect( - resolveRuntimeProviderWithOAuth({ - config: managedConfig(), - }), - ).rejects.toThrow(/requires login/); - }); - - it('rejects providers that set both apiKey and oauth on the same config', async () => { - const conflicting: PythinkerConfig = { - ...managedConfig(), - providers: { - 'managed:kimi-code': { - type: 'pythinker', - baseUrl: 'https://api.pythinker.com/coding/v1', - apiKey: 'static-key', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - }, - }; - - await expect( - resolveRuntimeProviderWithOAuth({ - config: conflicting, - resolveOAuthTokenProvider: () => ({ - getAccessToken: vi.fn().mockResolvedValue('unused'), - }), - }), - ).rejects.toThrow(/mutually exclusive/); - }); - - it('wraps token provider failures as login-required errors', async () => { - await expect( - resolveRuntimeProviderWithOAuth({ - config: managedConfig(), - resolveOAuthTokenProvider: () => ({ - getAccessToken: vi.fn().mockRejectedValue(new Error('missing token')), - }), - }), - ).rejects.toMatchObject({ - name: 'PythinkerError', - code: 'auth.login_required', - }); - }); - - it('logs token provider failures except plain login-required errors', async () => { - const log = testLogger(); - await expect( - resolveRuntimeProviderWithOAuth({ - config: managedConfig(), - log, - resolveOAuthTokenProvider: () => ({ - getAccessToken: vi.fn().mockRejectedValue(new Error('token endpoint down')), - }), - }), - ).rejects.toMatchObject({ code: 'auth.login_required' }); - expect(log.warn).toHaveBeenCalledWith( - 'oauth token fetch failed', - expect.objectContaining({ - providerName: 'managed:kimi-code', - error: expect.any(Error), - }), - ); - - vi.clearAllMocks(); - await expect( - resolveRuntimeProviderWithOAuth({ - config: managedConfig(), - log, - resolveOAuthTokenProvider: () => ({ - getAccessToken: vi.fn().mockRejectedValue( - new PythinkerError(ErrorCodes.AUTH_LOGIN_REQUIRED, 'not logged in'), - ), - }), - }), - ).rejects.toMatchObject({ code: 'auth.login_required' }); - expect(log.warn).not.toHaveBeenCalled(); - }); -}); - -function testLogger(): Logger { - const logger: Logger = { - error: vi.fn(), - warn: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - createChild: () => logger, - }; - return logger; -} diff --git a/packages/node-sdk/test/session-set-model.test.ts b/packages/node-sdk/test/session-set-model.test.ts index 631bc611..102fd1d7 100644 --- a/packages/node-sdk/test/session-set-model.test.ts +++ b/packages/node-sdk/test/session-set-model.test.ts @@ -1,6 +1,5 @@ import { join } from 'node:path'; -import { FileTokenStorage, type TokenInfo } from '@pythoughts/pythinker-code-oauth'; import { afterEach, describe, expect, it } from 'vitest'; import { createPythinkerHarness, type PythinkerError, type PythinkerHarness } from '#/index'; @@ -9,16 +8,6 @@ import { TEST_IDENTITY } from './test-identity'; const tempDirs: string[] = []; -function freshToken(): TokenInfo { - return { - accessToken: 'oauth-access-token', - refreshToken: 'oauth-refresh-token', - expiresAt: Math.floor(Date.now() / 1000) + 3600, - scope: '', - tokenType: 'Bearer', - expiresIn: 3600, - }; -} afterEach(async () => { await removeTempDirs(tempDirs); @@ -57,62 +46,6 @@ describe('Session.setModel', () => { } }); - it('resolves managed OAuth aliases before updating the runtime provider', async () => { - const homeDir = await makeTempDir(tempDirs, 'pythinker-sdk-model-home-'); - const workDir = await makeTempDir(tempDirs, 'pythinker-sdk-model-work-'); - await new FileTokenStorage(join(homeDir, 'credentials')).save('pythinker-code', freshToken()); - const harness = createPythinkerHarness({ homeDir, identity: TEST_IDENTITY }); - - try { - await harness.setConfig({ - providers: { - 'managed:kimi-code': { - type: 'pythinker', - baseUrl: 'https://api.pythinker.com/coding/v1', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - }, - models: { - 'pythinker-code/initial': { - provider: 'managed:kimi-code', - model: 'pythinker-initial', - maxContextSize: 262144, - }, - 'pythinker-code/pythinker-for-coding': { - provider: 'managed:kimi-code', - model: 'pythinker-for-coding', - maxContextSize: 262144, - }, - }, - defaultModel: 'pythinker-code/initial', - }); - const session = await harness.createSession({ - id: 'ses_model_oauth_wire', - workDir, - model: 'pythinker-code/initial', - }); - - await session.setModel('pythinker-code/pythinker-for-coding'); - - await expect(session.getStatus()).resolves.toMatchObject({ - model: 'pythinker-code/pythinker-for-coding', - }); - const configEvent = await waitForAgentWireEvent( - homeDir, - session.id, - 'config.update', - (event) => event['modelAlias'] === 'pythinker-code/pythinker-for-coding', - ); - expect(configEvent).toMatchObject({ - type: 'config.update', - modelAlias: 'pythinker-code/pythinker-for-coding', - }); - expect(configEvent).not.toHaveProperty('provider'); - } finally { - await harness.close(); - } - }); it('rejects empty model names', async () => { const homeDir = await makeTempDir(tempDirs, 'pythinker-sdk-model-home-'); diff --git a/packages/oauth/examples/kimi-oauth-smoke.ts b/packages/oauth/examples/kimi-oauth-smoke.ts deleted file mode 100644 index 4b23e016..00000000 --- a/packages/oauth/examples/kimi-oauth-smoke.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - applyManagedKimiCodeConfig, - KIMI_CODE_PROVIDER_NAME, - PythinkerOAuthToolkit, - type DeviceAuthorization, - type PythinkerHostIdentity, - type ManagedKimiConfigShape, -} from '@pythoughts/pythinker-code-oauth'; - -async function main(): Promise { - const explicitHomeDir = process.env['PYTHINKER_OAUTH_SMOKE_HOME']; - const homeDir = explicitHomeDir ?? (await mkdtemp(join(tmpdir(), 'kimi-oauth-smoke-'))); - const keepToken = shouldKeepToken(explicitHomeDir !== undefined); - const forceLogin = process.env['PYTHINKER_OAUTH_SMOKE_FORCE_LOGIN'] === '1'; - const config: ManagedKimiConfigShape = { providers: {} }; - - const toolkit = new PythinkerOAuthToolkit({ - homeDir, - identity: smokeIdentityFromEnv(), - configAdapter: { - read: () => config, - write: () => {}, - apply: applyManagedKimiCodeConfig, - configPath: '', - }, - }); - - process.stdout.write(`home: ${homeDir}\n`); - - try { - if (forceLogin) { - await toolkit.logout(KIMI_CODE_PROVIDER_NAME); - process.stdout.write('cleared existing smoke token\n'); - } - - const login = await toolkit.login(KIMI_CODE_PROVIDER_NAME, { - onDeviceCode: printDeviceCode, - }); - const status = await toolkit.status(KIMI_CODE_PROVIDER_NAME); - const accessToken = await toolkit.tokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(); - const usage = await toolkit.getManagedUsage(KIMI_CODE_PROVIDER_NAME); - - if (login.provision?.defaultModel === undefined) { - throw new Error('login did not provision a default model'); - } - if (status.providers[0]?.hasToken !== true) { - throw new Error('status did not report a stored token after login'); - } - if (accessToken.length === 0) { - throw new Error('token provider returned an empty access token'); - } - if (config.providers[KIMI_CODE_PROVIDER_NAME] === undefined) { - throw new Error('managed provider was not written to config'); - } - - process.stdout.write(`provider: ${login.providerName}\n`); - process.stdout.write(`default model: ${login.provision.defaultModel}\n`); - process.stdout.write(`models: ${String(login.provision.models.length)}\n`); - printUsage(usage); - process.stdout.write('oauth smoke passed\n'); - } finally { - if (!keepToken) { - await toolkit.logout(KIMI_CODE_PROVIDER_NAME).catch(() => {}); - } - if (explicitHomeDir === undefined && !keepToken) { - await rm(homeDir, { recursive: true, force: true }); - } - } -} - -function smokeIdentityFromEnv(): PythinkerHostIdentity { - const version = process.env['PYTHINKER_CODE_SMOKE_VERSION']; - if (version === undefined || version.trim().length === 0) { - throw new Error('PYTHINKER_CODE_SMOKE_VERSION is required for Kimi OAuth smoke.'); - } - return { - userAgentProduct: "pythinker-code-cli", - version, - }; -} - -function printDeviceCode(auth: DeviceAuthorization): void { - process.stdout.write( - [ - 'Complete Kimi OAuth device login:', - ` URL: ${auth.verificationUriComplete || auth.verificationUri}`, - ` Code: ${auth.userCode}`, - auth.expiresIn === null ? undefined : ` Expires in: ${String(auth.expiresIn)}s`, - '', - ] - .filter((line): line is string => line !== undefined) - .join('\n'), - ); -} - -function printUsage( - usage: Awaited['getManagedUsage']>>, -): void { - if (usage.kind === 'error') { - process.stderr.write(`usage request returned: ${usage.message}\n`); - return; - } - const summary = usage.summary; - if (summary === null) { - process.stdout.write(`usage: no summary, limits=${String(usage.limits.length)}\n`); - return; - } - process.stdout.write( - `usage: ${summary.label} ${String(summary.used)}/${String(summary.limit)}\n`, - ); -} - -function shouldKeepToken(hasExplicitHomeDir: boolean): boolean { - const value = process.env['PYTHINKER_OAUTH_SMOKE_KEEP_TOKEN']; - if (value !== undefined) return value === '1' || value === 'true'; - return hasExplicitHomeDir; -} - -try { - await main(); -} catch (error: unknown) { - console.error(error); - process.exitCode = 1; -} diff --git a/packages/oauth/src/constants.ts b/packages/oauth/src/constants.ts deleted file mode 100644 index 7e86c471..00000000 --- a/packages/oauth/src/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { OAuthFlowConfig } from './types'; - -export const DEFAULT_KIMI_CODE_OAUTH_HOST = 'https://auth.kimi.com'; - -export const KIMI_CODE_FLOW_CONFIG: OAuthFlowConfig = { - name: 'kimi-code', - oauthHost: - process.env['PYTHINKER_CODE_OAUTH_HOST'] ?? - process.env['PYTHINKER_OAUTH_HOST'] ?? - DEFAULT_KIMI_CODE_OAUTH_HOST, - clientId: '17e5f671-d194-4dfb-9706-5516cb48c098', -}; diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index 6113e16a..27c0eb5c 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -1,8 +1,6 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; -import type { ManagedKimiConfigShape } from './managed-kimi-code'; - -export type { ManagedKimiConfigShape }; +import type { PlatformConfigShape } from './open-platform'; /** * Identifies where a custom-registry-managed provider came from. The same @@ -292,7 +290,7 @@ function resolveCapabilities(model: CustomRegistryModelEntry): string[] { * refresh dispatcher can rediscover it later. */ export function applyCustomRegistryProvider( - config: ManagedKimiConfigShape, + config: PlatformConfigShape, entry: CustomRegistryProviderEntry, source: CustomRegistrySource, ): void { @@ -339,7 +337,7 @@ export function applyCustomRegistryProvider( * `removeOpenPlatformConfig`. */ export function removeCustomRegistryProvider( - config: ManagedKimiConfigShape, + config: PlatformConfigShape, providerId: string, ): void { delete config.providers[providerId]; @@ -383,7 +381,7 @@ export function removeCustomRegistryProvider( * registry". */ export function applyCustomRegistryEntries( - config: ManagedKimiConfigShape, + config: PlatformConfigShape, entries: Record, source: CustomRegistrySource, ): void { diff --git a/packages/oauth/src/errors.ts b/packages/oauth/src/errors.ts index cba91a2a..c6cbadb4 100644 --- a/packages/oauth/src/errors.ts +++ b/packages/oauth/src/errors.ts @@ -7,12 +7,6 @@ * or credentials are bad; drive user through `/login` again. * - `OAuthConnectionError`: transport-level OAuth request failure; callers * may retry the operation. - * - `DeviceCodeExpiredError`: device_code TTL ran out before user approved; - * restart the device flow. - * - `DeviceCodeTimeoutError`: local 15 min wall-clock budget exhausted - * before the user completed approval. - * - `RetryableRefreshError`: 429 / 5xx from token endpoint; the refresh - * helper retries with exponential backoff before surfacing this. */ export class OAuthError extends Error { @@ -36,23 +30,5 @@ export class OAuthConnectionError extends OAuthError { } } -export class DeviceCodeExpiredError extends OAuthError { - constructor(message = 'Device code expired.') { - super(message); - this.name = 'DeviceCodeExpiredError'; - } -} -export class DeviceCodeTimeoutError extends OAuthError { - constructor(message = 'Device authorization timed out locally.') { - super(message); - this.name = 'DeviceCodeTimeoutError'; - } -} -export class RetryableRefreshError extends OAuthError { - constructor(message: string) { - super(message); - this.name = 'RetryableRefreshError'; - } -} diff --git a/packages/oauth/src/identity.ts b/packages/oauth/src/identity.ts index dd15f32e..5b73daa1 100644 --- a/packages/oauth/src/identity.ts +++ b/packages/oauth/src/identity.ts @@ -13,10 +13,18 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { arch, hostname, release, type } from 'node:os'; import { join } from 'node:path'; -import type { DeviceHeaders } from './types'; - export const PYTHINKER_CODE_PLATFORM = 'pythinker_code_cli'; +/** Device identification headers a `pythinker`-wire endpoint expects. */ +export interface DeviceHeaders { + readonly 'X-Msh-Platform': string; + readonly 'X-Msh-Version': string; + readonly 'X-Msh-Device-Name': string; + readonly 'X-Msh-Device-Model': string; + readonly 'X-Msh-Os-Version': string; + readonly 'X-Msh-Device-Id': string; +} + export interface PythinkerHostIdentity { readonly userAgentProduct: string; readonly version: string; diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index f75c7b2b..83cc0b5e 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -1,11 +1,4 @@ -export { - DeviceCodeExpiredError, - DeviceCodeTimeoutError, - OAuthConnectionError, - OAuthError, - OAuthUnauthorizedError, - RetryableRefreshError, -} from './errors'; +export { OAuthConnectionError, OAuthError, OAuthUnauthorizedError } from './errors'; export { renderOAuthErrorPage, @@ -13,29 +6,9 @@ export { renderOpenAICodexOAuthSuccessPage, } from './oauth-pages'; -export type { - DeviceAuthorization, - DeviceHeaders, - OAuthFlowConfig, - OAuthStorageBackend, - TokenInfo, - TokenInfoWire, -} from './types'; -export { tokenFromWire, tokenToWire } from './types'; - -export type { TokenStorage } from './storage'; -export { FileTokenStorage } from './storage'; - -export type { DevicePollResult, RefreshOptions } from './oauth'; -export { pollDeviceToken, refreshAccessToken, requestDeviceAuthorization } from './oauth'; - -export type { LoginOptions, OAuthManagerOptions, OAuthRefreshOutcome } from './oauth-manager'; -export { OAuthManager, defaultRefreshThreshold, newInstanceId } from './oauth-manager'; - export { assertPythinkerHostIdentity, createPythinkerDefaultHeaders, - createPythinkerDeviceHeaders, createPythinkerDeviceId, createPythinkerUserAgent, PYTHINKER_CODE_PLATFORM, @@ -43,65 +16,6 @@ export { } from './identity'; export type { PythinkerHostIdentity, PythinkerIdentityOptions } from './identity'; -export { KIMI_CODE_FLOW_CONFIG } from './constants'; - -export { - applyManagedKimiCodeLogoutConfig, - applyManagedKimiCodeConfig, - clearManagedKimiCodeConfig, - fetchManagedKimiCodeModels, - kimiCodeEnvBaseUrl, - kimiCodeEnvOAuthHost, - KIMI_CODE_OAUTH_KEY, - KIMI_CODE_PLATFORM_ID, - KIMI_CODE_PROVIDER_NAME, - ManagedKimiCodeModelsAuthError, - provisionManagedKimiCodeConfig, - resolveKimiCodeLoginAuth, - resolveKimiCodeOAuthKey, - resolveKimiCodeOAuthRef, - resolveKimiCodeRuntimeAuth, -} from './managed-kimi-code'; -export type { - FetchManagedKimiCodeModelsOptions, - ManagedKimiCodeApplyResult, - ManagedKimiCodeCleanupResult, - ManagedKimiEnv, - ManagedKimiLoginAuth, - ManagedKimiCodeModelInfo, - ManagedKimiCodeProvisionResult, - ManagedKimiConfigAdapter, - ManagedKimiConfigShape, - ManagedKimiOAuthRef, - ManagedKimiOAuthRefInput, - ManagedKimiRuntimeAuth, - ProvisionManagedKimiCodeConfigOptions, -} from './managed-kimi-code'; - -export { - fetchManagedUsage, - formatDuration, - formatResetTime, - isManagedKimiCode, - kimiCodeBaseUrl, - kimiCodeUsageUrl, - parseManagedUsagePayload, -} from './managed-usage'; -export type { - FetchManagedUsageError, - FetchManagedUsageResult, - ParsedManagedUsage, - UsageRow, -} from './managed-usage'; - -export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback'; -export type { - FetchSubmitFeedbackError, - FetchSubmitFeedbackOk, - FetchSubmitFeedbackResult, - SubmitFeedbackBody, -} from './managed-feedback'; - export { applyOpenAICodexOAuthConfig, buildOpenAICodexAuthorizeUrl, @@ -137,12 +51,18 @@ export { OPEN_PLATFORMS, OPENAI_CODEX_OAUTH_LOGIN, OpenPlatformApiError, + parseSupportsThinkingType, removeOpenPlatformConfig, } from './open-platform'; export type { ApplyOpenPlatformResult, LoginPlatformProviderType, OpenPlatformDefinition, + PlatformConfigShape, + PlatformModelAlias, + PlatformModelInfo, + PlatformProviderConfig, + SupportsThinkingType, } from './open-platform'; export { @@ -161,16 +81,3 @@ export type { CustomRegistryProviderType, CustomRegistrySource, } from './custom-registry'; - -export { PythinkerOAuthToolkit, resolvePythinkerTokenStorageName } from './toolkit'; -export type { - AuthManagedUsageResult, - AuthProviderStatus, - AuthStatus, - BearerTokenProvider, - PythinkerOAuthLoginOptions, - PythinkerOAuthLoginResult, - PythinkerOAuthLogoutResult, - PythinkerOAuthTokenRef, - PythinkerOAuthToolkitOptions, -} from './toolkit'; diff --git a/packages/oauth/src/managed-feedback.ts b/packages/oauth/src/managed-feedback.ts deleted file mode 100644 index faa65eeb..00000000 --- a/packages/oauth/src/managed-feedback.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Submit user feedback to the managed Pythinker Code platform. - * - * POSTs a JSON body to `{kimiCodeBaseUrl}/feedback` with a Bearer access - * token. The client tags `version` with a `pythinker-code-` prefix so the - * backend can identify this client. - */ - -import { readApiErrorMessage } from './api-error'; -import { kimiCodeBaseUrl } from './managed-usage'; - -export interface SubmitFeedbackBody { - readonly session_id: string; - readonly content: string; - readonly version: string; - readonly os: string; - readonly model: string | null; -} - -export interface FetchSubmitFeedbackOk { - readonly kind: 'ok'; -} - -export interface FetchSubmitFeedbackError { - readonly kind: 'error'; - readonly status?: number; - readonly message: string; -} - -export type FetchSubmitFeedbackResult = FetchSubmitFeedbackOk | FetchSubmitFeedbackError; - -export function kimiCodeFeedbackUrl(): string { - return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/feedback`; -} - -export async function fetchSubmitFeedback( - url: string, - accessToken: string, - body: SubmitFeedbackBody, - opts: { timeoutMs?: number } = {}, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => { - controller.abort(); - }, opts.timeoutMs ?? 8000); - try { - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - if (!res.ok) { - return { - kind: 'error', - status: res.status, - message: await readApiErrorMessage( - res, - `Failed to submit feedback: HTTP ${String(res.status)}`, - ), - }; - } - return { kind: 'ok' }; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return { kind: 'error', message: 'Failed to submit feedback: request timed out.' }; - } - const msg = error instanceof Error ? error.message : String(error); - return { kind: 'error', message: `Failed to submit feedback: ${msg}` }; - } finally { - clearTimeout(timer); - } -} diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts deleted file mode 100644 index 8050c88b..00000000 --- a/packages/oauth/src/managed-kimi-code.ts +++ /dev/null @@ -1,739 +0,0 @@ -import { createHash } from 'node:crypto'; - -import { readApiErrorMessage } from './api-error'; -import { DEFAULT_KIMI_CODE_OAUTH_HOST } from './constants'; -import { OAuthUnauthorizedError } from './errors'; -import { DEFAULT_KIMI_CODE_BASE_URL, kimiCodeBaseUrl } from './managed-usage'; -import { isRecord } from './utils'; - -export const KIMI_CODE_PLATFORM_ID = 'kimi-code'; -export const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code'; -export const KIMI_CODE_OAUTH_KEY = 'oauth/kimi-code'; -const KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/kimi-code-env-'; - -/** - * Server-declared thinking toggle support from `/models`: - * - 'only' — thinking cannot be turned off (always-thinking) - * - 'no' — thinking is not supported at all - * - 'both' — thinking can be toggled on and off - * Absent on older servers — callers fall back to `supportsReasoning`. - */ -export type SupportsThinkingType = 'only' | 'no' | 'both'; - -/** - * Normalized model catalog entry returned by the managed Kimi Code - * `/models` endpoint; raw snake_case server fields map to these camelCase - * fields. - */ -export interface ManagedKimiCodeModelInfo { - readonly id: string; - readonly contextLength: number; - readonly supportsReasoning: boolean; - readonly supportedReasoningEfforts?: readonly string[]; - readonly supportsImageIn: boolean; - readonly supportsVideoIn: boolean; - readonly supportsToolUse?: boolean; - readonly supportsFastMode?: boolean; - readonly supportsThinkingType?: SupportsThinkingType; - readonly displayName?: string | undefined; -} - -export interface ManagedKimiCodeProvisionResult { - readonly providerName: typeof KIMI_CODE_PROVIDER_NAME; - readonly defaultModel: string; - readonly defaultThinking: boolean; - readonly models: readonly ManagedKimiCodeModelInfo[]; - readonly configPath?: string | undefined; -} - -export interface FetchManagedKimiCodeModelsOptions { - readonly accessToken: string; - readonly baseUrl?: string | undefined; - readonly fetchImpl?: typeof fetch | undefined; -} - -export interface ManagedKimiCodeApplyResult { - readonly defaultModel: string; - readonly defaultThinking: boolean; -} - -export interface ManagedKimiCodeCleanupResult { - readonly providerName: typeof KIMI_CODE_PROVIDER_NAME; - readonly removedProvider: boolean; - readonly removedModels: readonly string[]; - readonly defaultModelCleared: boolean; - readonly removedServices: readonly string[]; -} - -export interface ManagedKimiOAuthRef { - readonly storage: 'file' | 'keyring'; - readonly key: string; - readonly oauthHost?: string | undefined; -} - -export interface ManagedKimiOAuthRefInput { - readonly storage?: 'file' | 'keyring' | undefined; - readonly key?: string | undefined; - readonly oauthHost?: string | undefined; -} - -export interface ManagedKimiRuntimeAuth { - readonly baseUrl?: string | undefined; - readonly oauthRef: ManagedKimiOAuthRef; -} - -export interface ManagedKimiLoginAuth { - readonly baseUrl?: string | undefined; - readonly oauthHost?: string | undefined; - readonly oauthRef?: ManagedKimiOAuthRef | undefined; -} - -export interface ManagedKimiEnv { - readonly PYTHINKER_CODE_BASE_URL?: string | undefined; - readonly PYTHINKER_CODE_OAUTH_HOST?: string | undefined; - readonly PYTHINKER_OAUTH_HOST?: string | undefined; -} - -export class ManagedKimiCodeModelsAuthError extends OAuthUnauthorizedError { - readonly status: number; - readonly baseUrl: string; - - constructor(options: { - readonly status: number; - readonly baseUrl: string; - readonly message: string; - }) { - super( - `Kimi Code models endpoint ${options.baseUrl} rejected OAuth credentials: ${options.message}`, - ); - this.name = 'ManagedKimiCodeModelsAuthError'; - this.status = options.status; - this.baseUrl = options.baseUrl; - } -} - -export interface ManagedKimiProviderConfig { - type: 'pythinker'; - baseUrl?: string | undefined; - apiKey?: string | undefined; - oauth?: ManagedKimiOAuthRef | undefined; - readonly [key: string]: unknown; -} - -export interface ManagedKimiModelAlias { - provider: string; - model: string; - maxContextSize: number; - capabilities?: string[] | undefined; - supportEfforts?: readonly string[]; - displayName?: string | undefined; - readonly [key: string]: unknown; -} - -export interface ManagedKimiServiceConfig { - baseUrl?: string | undefined; - apiKey?: string | undefined; - oauth?: ManagedKimiOAuthRef | undefined; -} - -export interface ManagedKimiServicesConfig { - pythoughtsSearch?: ManagedKimiServiceConfig | undefined; - pythoughtsFetch?: ManagedKimiServiceConfig | undefined; - readonly [key: string]: unknown; -} - -export interface ManagedKimiConfigShape { - providers: Record>; - models?: Record> | undefined; - defaultModel?: string | undefined; - defaultThinking?: boolean | undefined; - thinking?: { - mode?: 'auto' | 'on' | 'off'; - effort?: string; - }; - services?: ManagedKimiServicesConfig | undefined; - [key: string]: unknown; -} - -export interface ManagedKimiConfigAdapter { - read(): Promise | TConfig; - write(config: TConfig): Promise | void; - apply( - config: TConfig, - input: { - readonly models: readonly ManagedKimiCodeModelInfo[]; - readonly baseUrl?: string | undefined; - readonly oauthKey?: string | undefined; - readonly oauthHost?: string | undefined; - readonly preserveDefaultModel?: boolean | undefined; - }, - ): ManagedKimiCodeApplyResult; - remove?(config: TConfig): void; - readonly configPath?: string | undefined; -} - -export interface ProvisionManagedKimiCodeConfigOptions { - readonly adapter: ManagedKimiConfigAdapter; - readonly accessToken: string; - readonly baseUrl?: string | undefined; - readonly oauthKey?: string | undefined; - readonly oauthHost?: string | undefined; - readonly preserveDefaultModel?: boolean | undefined; - readonly fetchImpl?: typeof fetch | undefined; -} - -function managedModelKey(modelId: string): string { - return `${KIMI_CODE_PLATFORM_ID}/${modelId}`; -} - -interface SelectedDefaultModel { - readonly modelKey: string; - readonly thinking: boolean; -} - -function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { - const caps = new Set(); - // supports_thinking_type is the full three-state declaration and wins over - // the legacy supports_reasoning boolean; absent (older servers) falls back. - switch (model.supportsThinkingType) { - case 'only': - caps.add('thinking'); - caps.add('always_thinking'); - break; - case 'both': - caps.add('thinking'); - break; - case 'no': - break; - case undefined: - if (model.supportsReasoning) caps.add('thinking'); - break; - } - if (model.supportsImageIn) caps.add('image_in'); - if (model.supportsVideoIn) caps.add('video_in'); - if (model.supportsToolUse ?? true) caps.add('tool_use'); - // Fast mode is opt-in: only add the capability when the server explicitly - // declares support (unlike tool_use, which defaults to true). - if (model.supportsFastMode === true) caps.add('fast_mode'); - return caps.size > 0 ? [...caps] : undefined; -} - -function defaultBaseUrl(baseUrl: string | undefined): string { - return (baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, ''); -} - -function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/, ''); -} - -function normalizeEndpoint(value: string): string { - return value.trim().replace(/\/+$/, ''); -} - -function persistedOAuthHost(options: { - readonly key: string; - readonly oauthHost?: string | undefined; -}): string | undefined { - const oauthHost = options.oauthHost; - const normalized = normalizeEndpoint(oauthHost ?? DEFAULT_KIMI_CODE_OAUTH_HOST); - if ( - options.key === KIMI_CODE_OAUTH_KEY && - normalized === normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST) - ) { - return undefined; - } - return normalized; -} - -function managedOAuthRef(options: { - readonly key: string; - readonly oauthHost?: string | undefined; - readonly storage?: 'file' | 'keyring' | undefined; -}): ManagedKimiOAuthRef { - const oauthHost = persistedOAuthHost(options); - return { - storage: options.storage ?? 'file', - key: options.key, - oauthHost, - }; -} - -function configuredOAuthRef( - oauthRef: ManagedKimiOAuthRefInput | undefined, -): ManagedKimiOAuthRef | undefined { - if (oauthRef === undefined) return undefined; - const key = oauthRef.key; - if (key === undefined) return undefined; - return managedOAuthRef({ - storage: oauthRef.storage, - key, - oauthHost: oauthRef.oauthHost, - }); -} - -/** - * Managed Kimi Code base URL from the environment. - */ -export function kimiCodeEnvBaseUrl(env: ManagedKimiEnv = process.env): string | undefined { - return env.PYTHINKER_CODE_BASE_URL; -} - -/** - * Managed Kimi Code OAuth host from the environment. - */ -export function kimiCodeEnvOAuthHost(env: ManagedKimiEnv = process.env): string | undefined { - return env.PYTHINKER_CODE_OAUTH_HOST ?? env.PYTHINKER_OAUTH_HOST; -} - -/** - * Returns the credential-storage key for an (oauthHost, baseUrl) pair: the - * global slot for defaults, otherwise a hash-scoped per-environment slot. - */ -export function resolveKimiCodeOAuthKey(options: { - readonly oauthHost?: string | undefined; - readonly baseUrl?: string | undefined; -}): string { - const oauthHost = normalizeEndpoint(options.oauthHost ?? DEFAULT_KIMI_CODE_OAUTH_HOST); - const baseUrl = defaultBaseUrl(options.baseUrl); - const defaultOauthHost = normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST); - const defaultApiBaseUrl = normalizeEndpoint(DEFAULT_KIMI_CODE_BASE_URL); - - if (oauthHost === defaultOauthHost && baseUrl === defaultApiBaseUrl) { - return KIMI_CODE_OAUTH_KEY; - } - - const digest = createHash('sha256') - .update(JSON.stringify({ oauthHost, baseUrl })) - .digest('hex') - .slice(0, 16); - return `${KIMI_CODE_SCOPED_OAUTH_KEY_PREFIX}${digest}`; -} - -/** - * Resolve the full managed-Kimi-Code OAuth ref (credential storage key + - * persisted host) for an (oauthHost, baseUrl) environment. - * - * Single source of truth for "which credential slot does this environment map - * to". Login, provisioning, and the runtime provider all derive their ref - * through here, so the slot a token is written to always matches the slot it - * is later read from — preventing the env-mismatch credential mix-ups this - * scoping is meant to fix. - */ -export function resolveKimiCodeOAuthRef(options: { - readonly oauthHost?: string | undefined; - readonly baseUrl?: string | undefined; -}): ManagedKimiOAuthRef { - return managedOAuthRef({ - key: resolveKimiCodeOAuthKey(options), - oauthHost: options.oauthHost, - }); -} - -/** - * Combines the configured base URL and OAuth ref with any env overrides into - * the runtime auth the managed provider should use, migrating to the env's - * credential slot when they disagree. - */ -export function resolveKimiCodeRuntimeAuth(options: { - readonly configuredBaseUrl?: string | undefined; - readonly configuredOAuthRef?: ManagedKimiOAuthRefInput | undefined; - readonly env?: ManagedKimiEnv | undefined; -}): ManagedKimiRuntimeAuth { - const env = options.env ?? process.env; - const envBaseUrl = kimiCodeEnvBaseUrl(env); - const envOAuthHost = kimiCodeEnvOAuthHost(env); - const hasEnvOverride = envBaseUrl !== undefined || envOAuthHost !== undefined; - const baseUrl = - envBaseUrl !== undefined ? normalizeBaseUrl(envBaseUrl) : options.configuredBaseUrl; - const expected = resolveKimiCodeOAuthRef({ - oauthHost: hasEnvOverride ? envOAuthHost : options.configuredOAuthRef?.oauthHost, - baseUrl, - }); - const configured = configuredOAuthRef(options.configuredOAuthRef); - if (configured === undefined) return { baseUrl, oauthRef: expected }; - if (hasEnvOverride) return { baseUrl, oauthRef: expected }; - if (configured.key !== expected.key) return { baseUrl, oauthRef: expected }; - return { baseUrl, oauthRef: configured }; -} - -/** - * Resolves the auth inputs for the login flow: explicit request options win, - * then env overrides, then the configured ref validated against the key the - * resolved base URL implies. - */ -export function resolveKimiCodeLoginAuth(options: { - readonly configuredBaseUrl?: string | undefined; - readonly configuredOAuthRef?: ManagedKimiOAuthRefInput | undefined; - readonly requestedBaseUrl?: string | undefined; - readonly requestedOAuthHost?: string | undefined; - readonly env?: ManagedKimiEnv | undefined; -}): ManagedKimiLoginAuth { - const env = options.env ?? process.env; - const envBaseUrl = kimiCodeEnvBaseUrl(env); - const envOAuthHost = kimiCodeEnvOAuthHost(env); - const hasOverride = - options.requestedBaseUrl !== undefined || - options.requestedOAuthHost !== undefined || - envBaseUrl !== undefined || - envOAuthHost !== undefined; - const baseUrl = - options.requestedBaseUrl !== undefined - ? normalizeBaseUrl(options.requestedBaseUrl) - : envBaseUrl !== undefined - ? normalizeBaseUrl(envBaseUrl) - : options.configuredBaseUrl; - const oauthHost = options.requestedOAuthHost ?? envOAuthHost; - if (hasOverride) return { baseUrl, oauthHost }; - - const configured = configuredOAuthRef(options.configuredOAuthRef); - if (configured === undefined) return { baseUrl, oauthHost }; - const expectedKey = resolveKimiCodeOAuthKey({ - oauthHost: configured.oauthHost, - baseUrl, - }); - return configured.key === expectedKey - ? { baseUrl, oauthHost, oauthRef: configured } - : { baseUrl, oauthHost }; -} - -function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { - if (!isRecord(item) || typeof item['id'] !== 'string' || item['id'].length === 0) { - return undefined; - } - const contextLength = Number(item['context_length']); - if (!Number.isInteger(contextLength) || contextLength <= 0) { - throw new Error(`Kimi Code model "${item['id']}" must include a positive context_length.`); - } - const displayName = item['display_name']; - const normalizedDisplayName = - typeof displayName === 'string' && displayName.length > 0 ? displayName : undefined; - const supportsToolUse = Object.hasOwn(item, 'supports_tool_use') - ? Boolean(item['supports_tool_use']) - : true; - return { - id: item['id'], - contextLength, - supportsReasoning: Boolean(item['supports_reasoning']), - supportsImageIn: Boolean(item['supports_image_in']), - supportsVideoIn: Boolean(item['supports_video_in']), - supportsToolUse, - supportsFastMode: Boolean(item['supports_fast_mode']), - supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']), - supportedReasoningEfforts: parseSupportedReasoningEfforts(item['supported_reasoning_efforts']), - displayName: normalizedDisplayName, - }; -} - -// Unknown or missing values resolve to undefined so the field is simply absent -// for older servers instead of being guessed. -function parseSupportedReasoningEfforts(value: unknown): readonly string[] | undefined { - if (!Array.isArray(value)) return undefined; - const efforts = value.filter((effort): effort is string => typeof effort === 'string'); - return efforts.length > 0 ? efforts : undefined; -} - -// Unknown or missing values resolve to undefined so callers fall back to the -// legacy supports_reasoning boolean instead of guessing. -export function parseSupportsThinkingType(value: unknown): SupportsThinkingType | undefined { - return value === 'only' || value === 'no' || value === 'both' ? value : undefined; -} - -/** - * Lists the managed Kimi Code models for an access token. Throws - * ManagedKimiCodeModelsAuthError on 401/402/403 so callers can trigger - * re-login. - */ -export async function fetchManagedKimiCodeModels( - options: FetchManagedKimiCodeModelsOptions, -): Promise { - const fetchImpl = options.fetchImpl ?? fetch; - const baseUrl = defaultBaseUrl(options.baseUrl); - const response = await fetchImpl(`${baseUrl}/models`, { - headers: { - Authorization: `Bearer ${options.accessToken}`, - Accept: 'application/json', - }, - }); - if (!response.ok) { - const message = await readApiErrorMessage( - response, - `Failed to list Kimi Code models (HTTP ${response.status}).`, - ); - if (response.status === 401 || response.status === 402 || response.status === 403) { - throw new ManagedKimiCodeModelsAuthError({ - status: response.status, - baseUrl, - message, - }); - } - throw new Error(message); - } - const payload: unknown = await response.json(); - if (!isRecord(payload) || !Array.isArray(payload['data'])) { - throw new Error(`Unexpected models response for ${baseUrl}.`); - } - return payload['data'] - .map((item) => toModelInfo(item)) - .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); -} - -/** - * Writes the managed provider, its model aliases, services, and a default - * model into the config in place. The current default is preserved when - * preserveDefaultModel is set and it still exists in the new catalog. - */ -export function applyManagedKimiCodeConfig( - config: ManagedKimiConfigShape, - options: { - readonly models: readonly ManagedKimiCodeModelInfo[]; - readonly baseUrl?: string | undefined; - readonly oauthKey?: string | undefined; - readonly oauthHost?: string | undefined; - readonly preserveDefaultModel?: boolean | undefined; - }, -): ManagedKimiCodeApplyResult { - if (options.models.length === 0) { - throw new Error('No models available for Kimi Code.'); - } - for (const model of options.models) { - assertPositiveContextLength(model); - } - - const baseUrl = defaultBaseUrl(options.baseUrl); - const oauth = - options.oauthKey !== undefined - ? managedOAuthRef({ key: options.oauthKey, oauthHost: options.oauthHost }) - : resolveKimiCodeOAuthRef({ baseUrl, oauthHost: options.oauthHost }); - const existingModels = config.models ?? {}; - const selectedDefault = selectDefaultModel(config, options.models, { - preserveExisting: options.preserveDefaultModel === true, - }); - - config.providers[KIMI_CODE_PROVIDER_NAME] = { - type: 'pythinker', - baseUrl, - apiKey: '', - oauth, - }; - - for (const [key, model] of Object.entries(existingModels)) { - if (isRecord(model) && model['provider'] === KIMI_CODE_PROVIDER_NAME) { - delete existingModels[key]; - } - } - for (const model of options.models) { - const capabilities = capabilitiesForModel(model); - existingModels[managedModelKey(model.id)] = { - provider: KIMI_CODE_PROVIDER_NAME, - model: model.id, - maxContextSize: model.contextLength, - capabilities, - supportEfforts: - model.supportedReasoningEfforts !== undefined - ? [...model.supportedReasoningEfforts] - : undefined, - displayName: model.displayName, - }; - } - - config.models = existingModels; - config.defaultModel = selectedDefault.modelKey; - config.defaultThinking = selectedDefault.thinking; - config.services = { - pythoughtsSearch: { - baseUrl: `${baseUrl}/search`, - apiKey: '', - oauth, - }, - pythoughtsFetch: { - baseUrl: `${baseUrl}/fetch`, - apiKey: '', - oauth, - }, - }; - - return { - defaultModel: selectedDefault.modelKey, - defaultThinking: selectedDefault.thinking, - }; -} - -/** - * Removes the managed provider and its aliases/services from the config, - * clearing defaults that pointed at them. - */ -export function applyManagedKimiCodeLogoutConfig(config: ManagedKimiConfigShape): void { - delete config.providers[KIMI_CODE_PROVIDER_NAME]; - - let removedDefaultModel = false; - const existingModels = config.models ?? {}; - for (const [key, model] of Object.entries(existingModels)) { - if (!isRecord(model) || model['provider'] !== KIMI_CODE_PROVIDER_NAME) continue; - delete existingModels[key]; - if (config.defaultModel === key) removedDefaultModel = true; - } - config.models = existingModels; - - if (removedDefaultModel) { - config.defaultModel = undefined; - } - - if (config['defaultProvider'] === KIMI_CODE_PROVIDER_NAME) { - config['defaultProvider'] = undefined; - } - - if (config.services !== undefined) { - delete config.services.pythoughtsSearch; - delete config.services.pythoughtsFetch; - if (Object.keys(config.services).length === 0) { - config.services = undefined; - } - } -} - -// The server's three-state declaration overrides any stale defaultThinking -// being preserved from an earlier config: an always-thinking model ('only') -// must never end up with thinking off, and a non-thinking model ('no') must -// never end up with thinking on. -function forcedThinking( - model: ManagedKimiCodeModelInfo | undefined, - fallback: boolean, -): boolean { - if (model?.supportsThinkingType === 'only') return true; - if (model?.supportsThinkingType === 'no') return false; - return fallback; -} - -function selectDefaultModel( - config: ManagedKimiConfigShape, - models: readonly ManagedKimiCodeModelInfo[], - options: { readonly preserveExisting: boolean }, -): SelectedDefaultModel { - const firstModel = models[0]; - if (firstModel === undefined) { - throw new Error('No models available for Kimi Code.'); - } - - const managedModels = new Map(models.map((model) => [managedModelKey(model.id), model])); - const existingModels = config.models ?? {}; - const currentDefault = - typeof config.defaultModel === 'string' && config.defaultModel.length > 0 - ? config.defaultModel - : undefined; - - if ( - options.preserveExisting && - currentDefault !== undefined && - canPreserveDefaultModel(existingModels, currentDefault, managedModels) - ) { - const preservedModel = managedModels.get(currentDefault); - return { - modelKey: currentDefault, - thinking: forcedThinking( - preservedModel, - config.defaultThinking ?? preservedModel?.supportsReasoning ?? false, - ), - }; - } - - return { - modelKey: managedModelKey(firstModel.id), - thinking: forcedThinking(firstModel, config.defaultThinking ?? firstModel.supportsReasoning), - }; -} - -function canPreserveDefaultModel( - existingModels: Record>, - defaultModel: string, - managedModels: ReadonlyMap, -): boolean { - if (managedModels.has(defaultModel)) return true; - const existing = existingModels[defaultModel]; - return isRecord(existing) && existing['provider'] !== KIMI_CODE_PROVIDER_NAME; -} - -/** - * Removes the managed provider, model aliases, and services from the config - * and reports what was actually removed. - */ -export function clearManagedKimiCodeConfig( - config: ManagedKimiConfigShape, -): ManagedKimiCodeCleanupResult { - const removedProvider = Object.hasOwn(config.providers, KIMI_CODE_PROVIDER_NAME); - delete config.providers[KIMI_CODE_PROVIDER_NAME]; - - const removedModels: string[] = []; - const models = config.models; - if (models !== undefined) { - for (const [key, model] of Object.entries(models)) { - if (!isRecord(model) || model['provider'] !== KIMI_CODE_PROVIDER_NAME) continue; - delete models[key]; - removedModels.push(key); - } - } - - let defaultModelCleared = false; - if (typeof config.defaultModel === 'string' && removedModels.includes(config.defaultModel)) { - config.defaultModel = undefined; - defaultModelCleared = true; - } - - const removedServices: string[] = []; - if (config.services?.pythoughtsSearch !== undefined) { - delete config.services.pythoughtsSearch; - removedServices.push('pythoughtsSearch'); - } - if (config.services?.pythoughtsFetch !== undefined) { - delete config.services.pythoughtsFetch; - removedServices.push('pythoughtsFetch'); - } - if (config.services !== undefined && Object.keys(config.services).length === 0) { - config.services = undefined; - } - - return { - providerName: KIMI_CODE_PROVIDER_NAME, - removedProvider, - removedModels, - defaultModelCleared, - removedServices, - }; -} - -function assertPositiveContextLength(model: ManagedKimiCodeModelInfo): void { - if (!Number.isInteger(model.contextLength) || model.contextLength <= 0) { - throw new Error(`Kimi Code model "${model.id}" must include a positive context_length.`); - } -} - -export async function provisionManagedKimiCodeConfigAfterLogin( - options: ProvisionManagedKimiCodeConfigOptions, -): Promise { - return provisionManagedKimiCodeConfig(options); -} - -/** - * Full login-completion flow: fetches models, applies the provider config via - * the adapter, and persists it. Returns the provisioned provider summary. - */ -export async function provisionManagedKimiCodeConfig( - options: ProvisionManagedKimiCodeConfigOptions, -): Promise { - const models = await fetchManagedKimiCodeModels(options); - const config = await options.adapter.read(); - const applied = options.adapter.apply(config, { - models, - baseUrl: options.baseUrl, - oauthKey: options.oauthKey, - oauthHost: options.oauthHost, - preserveDefaultModel: options.preserveDefaultModel, - }); - await options.adapter.write(config); - return { - providerName: KIMI_CODE_PROVIDER_NAME, - defaultModel: applied.defaultModel, - defaultThinking: applied.defaultThinking, - models, - configPath: options.adapter.configPath, - }; -} diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts deleted file mode 100644 index 04c4e818..00000000 --- a/packages/oauth/src/managed-usage.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Managed-platform usage fetch / parse. - * - * Only `managed:kimi-code` is supported today. The platform exposes a - * `/usages` endpoint that returns a payload of the shape: - * - * { - * "usage": { "name": "Weekly limit", "used": 40, "limit": 1000, "resetAt": "..." }, - * "limits": [ - * { "detail": {"used":1, "limit":100, "name":"5h limit"}, "window": {...} }, - * ... - * ] - * } - * - * The parser is intentionally loose because field spelling / casing - * drifted across versions (`used` vs `remaining`, `resetAt` vs - * `reset_at`, `duration+timeUnit` window labels, etc.). - */ - -import { readApiErrorMessage } from './api-error'; -import { isRecord } from './utils'; - -const MANAGED_PREFIX = 'managed:'; -const KIMI_CODE_PLATFORM_ID = 'kimi-code'; -export const DEFAULT_KIMI_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; - -export function isManagedKimiCode(providerKey?: string | null): boolean { - if (!providerKey) return false; - if (!providerKey.startsWith(MANAGED_PREFIX)) return false; - return providerKey.slice(MANAGED_PREFIX.length) === KIMI_CODE_PLATFORM_ID; -} - -export function kimiCodeBaseUrl(): string { - return process.env['PYTHINKER_CODE_BASE_URL'] ?? DEFAULT_KIMI_CODE_BASE_URL; -} - -export function kimiCodeUsageUrl(): string { - return `${kimiCodeBaseUrl().replace(/\/+$/, '')}/usages`; -} - -export interface UsageRow { - readonly label: string; - readonly used: number; - readonly limit: number; - readonly resetHint?: string | undefined; -} - -export interface ParsedManagedUsage { - readonly summary: UsageRow | null; - readonly limits: UsageRow[]; -} - -export function parseManagedUsagePayload(payload: unknown): ParsedManagedUsage { - if (typeof payload !== 'object' || payload === null) { - return { summary: null, limits: [] }; - } - const rec = payload as Record; - const summary = toUsageRow(rec['usage'], 'Weekly limit'); - const limits: UsageRow[] = []; - const rawLimits = rec['limits']; - if (Array.isArray(rawLimits)) { - for (let idx = 0; idx < rawLimits.length; idx++) { - const item = rawLimits[idx] as Record | undefined; - if (!item || typeof item !== 'object') continue; - const detailRaw = item['detail']; - const detail = isRecord(detailRaw) ? detailRaw : item; - const windowRaw = item['window']; - const window = isRecord(windowRaw) ? windowRaw : {}; - const label = limitLabel(item, detail, window, idx); - const row = toUsageRow(detail, label); - if (row !== null) limits.push(row); - } - } - return { summary, limits }; -} - -function toUsageRow(raw: unknown, defaultLabel: string): UsageRow | null { - if (!isRecord(raw)) return null; - const limit = toInt(raw['limit']); - let used = toInt(raw['used']); - if (used === null) { - const remaining = toInt(raw['remaining']); - if (remaining !== null && limit !== null) { - used = limit - remaining; - } - } - if (used === null && limit === null) return null; - const name = - typeof raw['name'] === 'string' - ? raw['name'] - : typeof raw['title'] === 'string' - ? raw['title'] - : defaultLabel; - const resetHint = resetHintFrom(raw); - return { - label: name, - used: used ?? 0, - limit: limit ?? 0, - resetHint, - }; -} - -function limitLabel( - item: Record, - detail: Record, - window: Record, - idx: number, -): string { - for (const key of ['name', 'title', 'scope']) { - const v = item[key] ?? detail[key]; - if (typeof v === 'string' && v.length > 0) return v; - } - const duration = toInt(window['duration'] ?? item['duration'] ?? detail['duration']); - const rawUnit = window['timeUnit'] ?? item['timeUnit'] ?? detail['timeUnit']; - const timeUnit = typeof rawUnit === 'string' ? rawUnit : ''; - if (duration !== null) { - if (timeUnit.includes('MINUTE')) { - if (duration >= 60 && duration % 60 === 0) return `${String(duration / 60)}h limit`; - return `${String(duration)}m limit`; - } - if (timeUnit.includes('HOUR')) return `${String(duration)}h limit`; - if (timeUnit.includes('DAY')) return `${String(duration)}d limit`; - return `${String(duration)}s limit`; - } - return `Limit #${String(idx + 1)}`; -} - -function resetHintFrom(raw: Record): string | undefined { - for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) { - const v = raw[key]; - if (typeof v === 'string' && v.length > 0) { - return formatResetTime(v); - } - } - for (const key of ['reset_in', 'resetIn', 'ttl', 'window']) { - const seconds = toInt(raw[key]); - if (seconds !== null && seconds > 0) { - return `resets in ${formatDuration(seconds)}`; - } - } - return undefined; -} - -export function formatResetTime(val: string): string { - let normalised = val; - // ISO with nano precision → trim to ms for JS Date. - if (normalised.includes('.') && normalised.endsWith('Z')) { - const [base, frac] = normalised.slice(0, -1).split('.'); - if (base !== undefined && frac !== undefined) { - normalised = `${base}.${frac.slice(0, 3)}Z`; - } - } - const parsed = Date.parse(normalised); - if (!Number.isFinite(parsed)) return `resets at ${val}`; - const diffSec = Math.floor((parsed - Date.now()) / 1000); - if (diffSec <= 0) return 'reset'; - return `resets in ${formatDuration(diffSec)}`; -} - -export function formatDuration(totalSeconds: number): string { - if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return '0s'; - const seconds = Math.floor(totalSeconds); - const days = Math.floor(seconds / 86_400); - const hours = Math.floor((seconds % 86_400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - const parts: string[] = []; - if (days) parts.push(`${String(days)}d`); - if (hours) parts.push(`${String(hours)}h`); - if (minutes) parts.push(`${String(minutes)}m`); - if (secs && parts.length === 0) parts.push(`${String(secs)}s`); - return parts.length > 0 ? parts.join(' ') : '0s'; -} - -function toInt(value: unknown): number | null { - if (typeof value === 'number') { - return Number.isFinite(value) ? Math.trunc(value) : null; - } - if (typeof value === 'string') { - const n = Number(value); - return Number.isFinite(n) ? Math.trunc(n) : null; - } - return null; -} - -// ── HTTP fetch ──────────────────────────────────────────────────────── - -export interface FetchManagedUsageResult { - readonly kind: 'ok'; - readonly parsed: ParsedManagedUsage; -} - -export interface FetchManagedUsageError { - readonly kind: 'error'; - readonly status?: number; - readonly message: string; -} - -export async function fetchManagedUsage( - url: string, - accessToken: string, - opts: { timeoutMs?: number } = {}, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => { - controller.abort(); - }, opts.timeoutMs ?? 8000); - try { - const res = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - signal: controller.signal, - }); - if (!res.ok) { - const status = res.status; - const hint = - status === 401 - ? 'Authorization failed. Please check your API key (try /login).' - : status === 404 - ? 'Usage endpoint not available. Try Kimi For Coding.' - : `Failed to fetch usage: HTTP ${String(status)}`; - return { kind: 'error', status, message: await readApiErrorMessage(res, hint) }; - } - const json: unknown = await res.json(); - return { kind: 'ok', parsed: parseManagedUsagePayload(json) }; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return { kind: 'error', message: 'Failed to fetch usage: request timed out.' }; - } - const msg = error instanceof Error ? error.message : String(error); - return { kind: 'error', message: `Failed to fetch usage: ${msg}` }; - } finally { - clearTimeout(timer); - } -} diff --git a/packages/oauth/src/oauth-manager.ts b/packages/oauth/src/oauth-manager.ts deleted file mode 100644 index 78b75426..00000000 --- a/packages/oauth/src/oauth-manager.ts +++ /dev/null @@ -1,480 +0,0 @@ -/** - * OAuthManager — per-provider token lifecycle (load / refresh / login / logout). - * - * - Lazy refresh on `ensureFresh()` — no background loop - * - Single-process concurrency: in-memory mutex serialises refreshes - * - Multi-process coordination: before + after storage re-read, so a - * concurrent refresh from another CLI process is detected (best-effort) - * - `login()`: device code flow with a 15 min local timeout - * - `logout()`: delete stored token - * - * All network / clock / storage operations are injectable for tests. - */ - -import { randomUUID } from 'node:crypto'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; - -import lockfile from 'proper-lockfile'; - -import { DeviceCodeTimeoutError, OAuthError, OAuthUnauthorizedError } from './errors'; -import { pollDeviceToken, refreshAccessToken, requestDeviceAuthorization } from './oauth'; -import type { DevicePollResult, RefreshOptions } from './oauth'; -import type { TokenStorage } from './storage'; -import { classifyToken, revokedTombstone, type TokenState } from './token-state'; -import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types'; - -const MIN_REFRESH_THRESHOLD_SECONDS = 300; -const REFRESH_THRESHOLD_RATIO = 0.5; -const DEFAULT_DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000; - -export function defaultRefreshThreshold(expiresIn: number): number { - if (expiresIn > 0) { - return Math.max(MIN_REFRESH_THRESHOLD_SECONDS, expiresIn * REFRESH_THRESHOLD_RATIO); - } - return MIN_REFRESH_THRESHOLD_SECONDS; -} - -type Sleep = (ms: number) => Promise; -const defaultSleep: Sleep = (ms) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); -type ManagerRefreshOptions = Omit; - -export type OAuthRefreshOutcome = - | { readonly success: true } - | { readonly success: false; readonly reason: 'unauthorized' | 'network_or_other' }; - -export interface OAuthManagerOptions { - readonly config: OAuthFlowConfig; - readonly storage: TokenStorage; - readonly refreshThreshold?: ((expiresIn: number) => number) | undefined; - readonly deviceCodeTimeoutMs?: number | undefined; - readonly now?: (() => number) | undefined; - readonly sleep?: Sleep | undefined; - /** Observer invoked synchronously when a refresh attempt resolves. */ - readonly onRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; - readonly refreshTokenImpl?: - | (( - config: OAuthFlowConfig, - refreshToken: string, - options?: ManagerRefreshOptions, - ) => Promise) - | undefined; - readonly requestDeviceImpl?: - | ((config: OAuthFlowConfig) => Promise) - | undefined; - readonly pollDeviceImpl?: - | ((config: OAuthFlowConfig, deviceCode: string) => Promise) - | undefined; - readonly deviceHeaders?: (() => DeviceHeaders | undefined) | undefined; - /** - * Root directory for per-provider lock files; resolves to - * `{configDir}/oauth/{providerName}.lock`. - * - * **Production callers MUST pass this explicitly** (PythinkerCoreClient / - * session-manager wire it through from the resolved config root). A - * missing `configDir` disables the cross-process lock entirely, so - * silently falling back to an env var in production would mask a - * genuine mis-wiring. - * - * When omitted AND `process.env.NODE_ENV === 'test'`, the manager - * falls back to `process.env.PYTHINKER_CODE_HOME` so multi-process test - * harnesses don't need to thread the dir through every fixture. In - * production the fallback is inert. Windows platforms and - * `process.env.PYTHINKER_DISABLE_OAUTH_LOCK === '1'` always skip; the - * "re-read storage" fail-safe remains as a best-effort coordinator. - */ - readonly configDir?: string | undefined; -} - -export interface LoginOptions { - readonly onDeviceCode?: ((auth: DeviceAuthorization) => Promise | void) | undefined; - readonly signal?: AbortSignal | undefined; -} - -export class OAuthManager { - private readonly config: OAuthFlowConfig; - private readonly storage: TokenStorage; - private readonly refreshThresholdFn: (expiresIn: number) => number; - private readonly deviceCodeTimeoutMs: number; - private readonly now: () => number; - private readonly sleep: Sleep; - private readonly refreshImpl: NonNullable; - private readonly requestImpl: NonNullable; - private readonly pollImpl: NonNullable; - private readonly deviceHeaders: (() => DeviceHeaders | undefined) | undefined; - private readonly configDir: string | undefined; - private readonly onRefresh: ((outcome: OAuthRefreshOutcome) => void) | undefined; - - /** - * In-flight refresh coalescer: one refresh per ensureFresh race. - * - * Tracks the `force` flag of the in-flight call so a later `force=true` - * caller cannot piggyback a non-force result that may short-circuit on - * a still-cached token. A non-force caller is happy with any settled - * outcome and may piggyback either kind. - */ - private inFlightRefresh: { promise: Promise; force: boolean } | undefined; - - constructor(options: OAuthManagerOptions) { - this.config = options.config; - this.storage = options.storage; - this.refreshThresholdFn = options.refreshThreshold ?? defaultRefreshThreshold; - this.deviceCodeTimeoutMs = options.deviceCodeTimeoutMs ?? DEFAULT_DEVICE_CODE_TIMEOUT_MS; - this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); - this.sleep = options.sleep ?? defaultSleep; - this.deviceHeaders = options.deviceHeaders; - this.onRefresh = options.onRefresh; - this.refreshImpl = - options.refreshTokenImpl ?? - ((config, refreshToken, refreshOptions) => - refreshAccessToken(config, refreshToken, { - ...refreshOptions, - deviceHeaders: this.resolveDeviceHeaders(), - })); - this.requestImpl = - options.requestDeviceImpl ?? - ((config) => - requestDeviceAuthorization(config, { - deviceHeaders: this.resolveDeviceHeaders(), - })); - this.pollImpl = - options.pollDeviceImpl ?? - ((config, deviceCode) => - pollDeviceToken(config, deviceCode, { - deviceHeaders: this.resolveDeviceHeaders(), - })); - // The `PYTHINKER_CODE_HOME` fallback MUST stay test-only so production - // entry points can't silently run without a lock just because the - // env happens to be unset. vitest sets `NODE_ENV='test'` by default, - // so multi-process test workers still pick up the test home path. - const envConfigDir = - process.env['NODE_ENV'] === 'test' ? process.env['PYTHINKER_CODE_HOME'] : undefined; - this.configDir = options.configDir ?? envConfigDir; - } - - private resolveDeviceHeaders(): DeviceHeaders | undefined { - return this.deviceHeaders?.(); - } - - private async loadState(): Promise { - return classifyToken(await this.storage.load(this.config.name)); - } - - private notifyRefresh(outcome: OAuthRefreshOutcome): void { - if (this.onRefresh === undefined) return; - try { - this.onRefresh(outcome); - } catch { - // Observer must not affect OAuth flow. - } - } - - /** - * Resolve the sentinel target file `proper-lockfile` locks against. - * `proper-lockfile.lock(target)` creates `${target}.lock` as the - * actual lock directory, so the real lockfile on disk ends up at - * `{configDir}/oauth/{providerName}.lock`. Returns `undefined` when - * locking is opted out (no configDir, Windows, env kill switch). - */ - private resolveLockTarget(): string | undefined { - if (process.platform === 'win32') return undefined; - if (process.env['PYTHINKER_DISABLE_OAUTH_LOCK'] === '1') return undefined; - if (this.configDir === undefined) return undefined; - return `${this.configDir}/oauth/${this.config.name}`; - } - - /** - * Acquire the cross-process lock around the refresh critical section. - * Returns a `release` closure; when locking is disabled returns a no-op. - * If locking is configured but cannot be acquired, fail closed rather than - * refreshing with no lock and racing refresh_token rotation. - */ - private async acquireRefreshLock(): Promise<() => Promise> { - const target = this.resolveLockTarget(); - if (target === undefined) return async () => {}; - - // proper-lockfile requires the target path to exist. We create - // an empty sentinel file; the real lock indicator is the sibling - // `{target}.lock` directory proper-lockfile creates and cleans - // up on release (→ test oracle `{configDir}/oauth/{name}.lock` - // must be absent after a graceful exit). - try { - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, '', { flag: 'a' }); - } catch (error) { - throw new OAuthError( - `Unable to prepare OAuth refresh lock for "${this.config.name}": ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - try { - const release = await lockfile.lock(target, { - retries: { retries: 120, factor: 1, minTimeout: 500, maxTimeout: 1_000 }, - stale: 5_000, - realpath: false, - }); - return async () => { - try { - await release(); - } catch { - /* ignore release-after-stale */ - } - }; - } catch (error) { - throw new OAuthError( - `Unable to acquire OAuth refresh lock for "${this.config.name}": ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - - async hasToken(): Promise { - return (await this.loadState()).kind === 'valid'; - } - - async getCachedAccessToken(): Promise { - const state = await this.loadState(); - return state.kind === 'valid' ? state.token.accessToken : undefined; - } - - async logout(): Promise { - await this.storage.remove(this.config.name); - } - - /** - * Return a valid access_token, refreshing if within the dynamic threshold. - * Throws if no token is persisted (caller should invoke `/login`). - */ - async ensureFresh(options: { force?: boolean } = {}): Promise { - const force = options.force === true; - const current = this.inFlightRefresh; - if (current !== undefined) { - // A non-force caller is happy with whatever the in-flight call - // settles to. A force caller can also piggyback another force - // call. Only force-on-top-of-non-force needs its own refresh, - // because the non-force call may return a still-cached token - // that doesn't satisfy the caller-requested forced rotation. - if (!force || current.force) { - return current.promise; - } - // Wait for the non-force call to settle (success or failure), - // then start our own forced refresh. Swallowing rejection here - // is safe: the non-force caller already owns surfacing that error. - return current.promise.catch(() => undefined).then(() => this.ensureFresh(options)); - } - - const promise = this.doEnsureFresh(force).finally(() => { - // Only clear our own slot. A later, replacement in-flight (e.g. a - // queued force after this non-force resolves) must not be evicted - // by our cleanup. - if (this.inFlightRefresh?.promise === promise) { - this.inFlightRefresh = undefined; - } - }); - this.inFlightRefresh = { promise, force }; - return promise; - } - - private async doEnsureFresh(force: boolean): Promise { - const initial = await this.loadState(); - switch (initial.kind) { - case 'missing': - throw new OAuthUnauthorizedError( - `No token for "${this.config.name}". Run /login to authenticate.`, - ); - case 'revoked': - // A prior 401 (possibly from another process) tombstoned this token. - // Surface as unauthorized so callers route into the re-login flow - // instead of treating it as a transient error. - throw new OAuthUnauthorizedError( - `Stored token for "${this.config.name}" was rejected; re-login required.`, - ); - case 'valid': - break; - } - const token = initial.token; - - const needRefresh = this.shouldRefreshToken(token, force); - if (!needRefresh) { - return token.accessToken; - } - - // Acquire the cross-process lock before entering the refresh critical - // section. Concurrent CLI processes serialise on - // `{configDir}/oauth/{providerName}.lock` via `proper-lockfile`. - // Post-acquire we re-read storage: if a peer already rotated the - // token, short-circuit and return theirs instead of burning an - // extra refresh. - const release = await this.acquireRefreshLock(); - try { - // Post-lock re-read. The semantics: - // - // • force=false: the normal threshold short-circuit still - // applies. - // • force=true: still refresh unless storage changed while we - // waited for the lock. That preserves caller-requested forced - // refreshes for unchanged tokens while coalescing real peer - // refreshes across processes. - const afterLock = await this.loadState(); - let activeToken: TokenInfo; - switch (afterLock.kind) { - case 'revoked': - // Peer process tombstoned the file while we waited for the lock. - throw new OAuthUnauthorizedError( - `Stored token for "${this.config.name}" was rejected; re-login required.`, - ); - case 'missing': - // File disappeared (e.g. logout from another process) while we - // waited for the lock; fall back to the snapshot we read pre-lock. - activeToken = token; - break; - case 'valid': { - const after = afterLock.token; - if (!this.shouldRefreshToken(after, force)) { - return after.accessToken; - } - if (force) { - const changedWhileWaiting = - after.refreshToken !== token.refreshToken || - after.accessToken !== token.accessToken || - after.expiresAt !== token.expiresAt || - after.expiresIn !== token.expiresIn; - if (changedWhileWaiting) { - return after.accessToken; - } - } - activeToken = after; - break; - } - } - - if (activeToken.refreshToken.length === 0) { - throw new OAuthUnauthorizedError( - `Token for "${this.config.name}" has no refresh_token; re-login required.`, - ); - } - - try { - const refreshed = await this.refreshImpl(this.config, activeToken.refreshToken); - await this.storage.save(this.config.name, refreshed); - this.notifyRefresh({ success: true }); - return refreshed.accessToken; - } catch (error) { - if (error instanceof OAuthUnauthorizedError) { - // 401/403 might mean (a) refresh_token genuinely revoked or - // (b) another process rotated the refresh_token while we were - // mid-flight. Check (b) first: re-read storage, and if a peer - // wrote a different valid refresh_token, treat the 401 as a - // stale-token race and use the rotated value. - await this.sleep(100); - const recovery = await this.loadState(); - if ( - recovery.kind === 'valid' && - recovery.token.refreshToken !== activeToken.refreshToken - ) { - this.notifyRefresh({ success: true }); - return recovery.token.accessToken; - } - // No peer rotated — record the rejection on disk as a tombstone so - // a fresh process (with no in-memory state) won't re-attempt the - // same dead refresh_token. The file stays present so peers see - // "previously logged in, now rejected" instead of "never logged in". - await this.storage.save(this.config.name, revokedTombstone(activeToken)); - this.notifyRefresh({ success: false, reason: 'unauthorized' }); - } else { - this.notifyRefresh({ success: false, reason: 'network_or_other' }); - } - throw error; - } - } finally { - await release(); - } - } - - /** - * Drive the device code flow end-to-end. `onDeviceCode` is called once - * the user code is available so the caller can display it. - * - * Local 15-min wall-clock budget guards against forever-pending flows. - */ - async login(options: LoginOptions = {}): Promise { - const startedAt = this.now(); - const deadlineAt = startedAt + Math.ceil(this.deviceCodeTimeoutMs / 1000); - - while (true) { - const auth = await this.requestImpl(this.config); - await options.onDeviceCode?.(auth); - - // RFC 8628 §3.5: clients must add at least 5s on `slow_down` and - // continue polling at the increased interval thereafter. - let currentInterval = Math.max(auth.interval, 1); - // Poll until success, denial, local timeout, or expired_token (retry outer). - let deviceExpired = false; - while (true) { - this.throwIfAborted(options.signal); - if (this.now() >= deadlineAt) { - throw new DeviceCodeTimeoutError( - `Device authorization timed out after ${Math.ceil(this.deviceCodeTimeoutMs / 1000)}s`, - ); - } - - const result = await this.pollImpl(this.config, auth.deviceCode); - if (result.kind === 'success') { - await this.storage.save(this.config.name, result.token); - return result.token; - } - if (result.kind === 'denied') { - throw new OAuthError( - `Authorization denied${result.description ? `: ${result.description}` : ''}`, - ); - } - if (result.kind === 'expired') { - deviceExpired = true; - break; - } - // pending: bump interval permanently when server requests slow_down. - if (result.errorCode === 'slow_down') { - currentInterval += 5; - } - await this.sleep(currentInterval * 1000); - } - if (!deviceExpired) break; - // Otherwise loop outer to request a new device code. - // Guard: if we're already past the deadline, bail. - if (this.now() >= deadlineAt) { - throw new DeviceCodeTimeoutError('Device authorization timed out'); - } - } - - // Unreachable — inner loop always returns or throws. - throw new OAuthError('Device flow ended unexpectedly'); - } - - private shouldRefreshToken(token: TokenInfo, force: boolean): boolean { - if (force) return true; - if (token.expiresAt === 0) return false; - const remaining = token.expiresAt - this.now(); - return remaining < this.refreshThresholdFn(token.expiresIn); - } - - private throwIfAborted(signal: AbortSignal | undefined): void { - if (signal?.aborted === true) { - throw new OAuthError('Login aborted by caller'); - } - } -} - -/** - * Generate a synthetic OAuth client instance id. Used by `/login` to - * correlate device flows with the CLI instance without depending on - * runtime state. Not required by the protocol — purely for diagnostics. - */ -export function newInstanceId(): string { - return randomUUID(); -} diff --git a/packages/oauth/src/oauth.ts b/packages/oauth/src/oauth.ts deleted file mode 100644 index 963534da..00000000 --- a/packages/oauth/src/oauth.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Device Code OAuth flow — pure HTTP wrappers. - * - * Three endpoints, all POST form-encoded to the OAuth host: - * - `/api/oauth/device_authorization` → DeviceAuthorization - * - `/api/oauth/token` (grant_type=device_code) → polling result - * - `/api/oauth/token` (grant_type=refresh_token) → refreshed TokenInfo - * - * No state is kept here — `OAuthManager` drives the flow and decides - * when to poll / refresh / store. - */ - -import { extractApiErrorMessage } from './api-error'; -import { - OAuthConnectionError, - OAuthError, - OAuthUnauthorizedError, - RetryableRefreshError, -} from './errors'; -import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types'; -import { isRecord } from './utils'; - -const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]); - -function pickErrorDetail(data: Record): string { - return extractApiErrorMessage(data) ?? 'unknown'; -} - -function tokenFromResponse(payload: Record): TokenInfo { - // Required-field validation. Reject responses that are missing - // any of the three load-bearing fields rather than persisting empty - // strings that will fail mysteriously later. - const accessToken = payload['access_token']; - if (typeof accessToken !== 'string' || accessToken.length === 0) { - throw new OAuthError('OAuth response missing access_token'); - } - const refreshToken = payload['refresh_token']; - if (typeof refreshToken !== 'string' || refreshToken.length === 0) { - throw new OAuthError('OAuth response missing refresh_token'); - } - const expiresInRaw = payload['expires_in']; - const expiresIn = Number(expiresInRaw); - if (!Number.isFinite(expiresIn) || expiresIn <= 0) { - throw new OAuthError('OAuth response missing or invalid expires_in'); - } - return { - accessToken, - refreshToken, - expiresAt: Math.floor(Date.now() / 1000) + expiresIn, - scope: typeof payload['scope'] === 'string' ? payload['scope'] : '', - tokenType: typeof payload['token_type'] === 'string' ? payload['token_type'] : 'Bearer', - expiresIn, - }; -} - -/** HTTP client default timeout for OAuth requests. */ -const DEFAULT_HTTP_TIMEOUT_MS = 30_000; - -async function postForm( - url: string, - params: Record, - deviceHeaders?: DeviceHeaders | undefined, - options?: { timeoutMs?: number; signal?: AbortSignal }, -): Promise<{ status: number; data: Record }> { - const timeoutMs = options?.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS; - const body = new URLSearchParams(params).toString(); - // Compose a timeout signal with the optional caller signal. - const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)]; - if (options?.signal !== undefined) signals.push(options.signal); - const signal = AbortSignal.any(signals); - let response: Response; - try { - response = await fetch(url, { - method: 'POST', - headers: { - ...deviceHeaders, - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, - body, - signal, - }); - } catch (error) { - throw new OAuthConnectionError( - `OAuth request to ${url} failed: ${describeFetchFailure(error)}`, - { cause: error }, - ); - } - const status = response.status; - let data: Record = {}; - try { - const parsed: unknown = await response.json(); - if (isRecord(parsed)) data = parsed; - } catch { - // Non-JSON response — leave data empty; caller interprets by status. - } - return { status, data }; -} - -function describeFetchFailure(error: unknown): string { - if (!(error instanceof Error)) return String(error); - const messages = new Set(); - let current: Error | undefined = error; - while (current !== undefined) { - messages.add(current.message); - current = current.cause instanceof Error ? current.cause : undefined; - } - return [...messages].join(': '); -} - -/** - * Whether a URL is safe to hand to a browser. - * - * The verification URLs come off the wire and every renderer opens them with - * the host's "open this externally" API, so a provider that answered with - * `file:`, `javascript:`, or an installed app's custom scheme would have the - * agent launch it. Checked here, at the boundary the response is parsed, rather - * than in each renderer. - */ -function isHttpsUrl(value: string): boolean { - try { - return new URL(value).protocol === 'https:'; - } catch { - return false; - } -} - -// ── requestDeviceAuthorization ──────────────────────────────────────── - -export async function requestDeviceAuthorization( - config: OAuthFlowConfig, - options: { readonly deviceHeaders?: DeviceHeaders | undefined }, -): Promise { - const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/device_authorization`; - const { status, data } = await postForm( - url, - { client_id: config.clientId }, - options.deviceHeaders, - ); - - if (status !== 200) { - throw new OAuthError( - `Device authorization failed (HTTP ${status}): ${pickErrorDetail(data)}`, - ); - } - - // Required-field validation for the device authorization response. - const userCode = data['user_code']; - const deviceCode = data['device_code']; - const verificationUriComplete = data['verification_uri_complete']; - if (typeof userCode !== 'string' || userCode.length === 0) { - throw new OAuthError('Device authorization response missing user_code'); - } - if (typeof deviceCode !== 'string' || deviceCode.length === 0) { - throw new OAuthError('Device authorization response missing device_code'); - } - if (typeof verificationUriComplete !== 'string' || verificationUriComplete.length === 0) { - throw new OAuthError('Device authorization response missing verification_uri_complete'); - } - if (!isHttpsUrl(verificationUriComplete)) { - throw new OAuthError('Device authorization response has a non-HTTPS verification_uri_complete'); - } - const verificationUri = data['verification_uri']; - if (typeof verificationUri === 'string' && verificationUri.length > 0 - && !isHttpsUrl(verificationUri)) { - throw new OAuthError('Device authorization response has a non-HTTPS verification_uri'); - } - - return { - userCode, - deviceCode, - verificationUri: typeof verificationUri === 'string' ? verificationUri : '', - verificationUriComplete, - expiresIn: data['expires_in'] !== undefined ? Number(data['expires_in']) : null, - interval: Number(data['interval'] ?? 5), - }; -} - -// ── pollDeviceToken ─────────────────────────────────────────────────── - -export type DevicePollResult = - | { readonly kind: 'success'; readonly token: TokenInfo } - | { readonly kind: 'pending'; readonly errorCode: string; readonly description: string } - | { readonly kind: 'expired' } - | { readonly kind: 'denied'; readonly description: string }; - -export async function pollDeviceToken( - config: OAuthFlowConfig, - deviceCode: string, - options: { readonly deviceHeaders?: DeviceHeaders | undefined }, -): Promise { - const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/token`; - const { status, data } = await postForm( - url, - { - client_id: config.clientId, - device_code: deviceCode, - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - }, - options.deviceHeaders, - ); - - if (status === 200 && typeof data['access_token'] === 'string') { - return { kind: 'success', token: tokenFromResponse(data) }; - } - - if (status >= 500) { - throw new OAuthError( - `Device token polling server error (HTTP ${status}): ${pickErrorDetail(data)}`, - ); - } - - const errorCode = typeof data['error'] === 'string' ? data['error'] : 'unknown_error'; - const detail = extractApiErrorMessage(data); - const description = - typeof data['error_description'] === 'string' ? data['error_description'] : (detail ?? ''); - switch (errorCode) { - case 'authorization_pending': - case 'slow_down': - return { kind: 'pending', errorCode, description }; - case 'expired_token': - return { kind: 'expired' }; - case 'access_denied': - return { kind: 'denied', description }; - default: - throw new OAuthError( - `Device token polling failed (HTTP ${status}): ${detail ?? `${errorCode} ${description}`}`, - ); - } -} - -// ── refreshAccessToken ──────────────────────────────────────────────── - -export interface RefreshOptions { - readonly deviceHeaders?: DeviceHeaders | undefined; - readonly maxRetries?: number | undefined; - /** - * Backoff between retries in ms. Defaults to `2 ** attempt * 1000` (1s, 2s). - * Accepts an attempt-indexed callable for testability (set to `() => 0`). - */ - readonly backoffMs?: ((attempt: number) => number) | undefined; - readonly sleep?: ((ms: number) => Promise) | undefined; -} - -export async function refreshAccessToken( - config: OAuthFlowConfig, - refreshToken: string, - options: RefreshOptions, -): Promise { - const maxRetries = options.maxRetries ?? 3; - const backoff = options.backoffMs ?? ((attempt) => 2 ** attempt * 1000); - const sleep = - options.sleep ?? - ((ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - })); - const url = `${config.oauthHost.replace(/\/$/, '')}/api/oauth/token`; - - let lastError: Error | undefined; - for (let attempt = 0; attempt < maxRetries; attempt += 1) { - let status: number; - let data: Record; - try { - ({ status, data } = await postForm( - url, - { - client_id: config.clientId, - grant_type: 'refresh_token', - refresh_token: refreshToken, - }, - options.deviceHeaders, - )); - } catch (error) { - // Transport-level failure (DNS, connection refused, timeout). Treat - // as retryable to match Python's `aiohttp.ClientError` handling. - lastError = error instanceof Error ? error : new OAuthError(String(error)); - if (attempt < maxRetries - 1) { - await sleep(backoff(attempt)); - continue; - } - throw lastError instanceof Error ? lastError : new OAuthError(String(lastError)); - } - - if (status === 200 && typeof data['access_token'] === 'string') { - return tokenFromResponse(data); - } - - const errorCode = typeof data['error'] === 'string' ? data['error'] : ''; - const detail = extractApiErrorMessage(data); - if (status === 401 || status === 403 || errorCode === 'invalid_grant') { - throw new OAuthUnauthorizedError(detail ?? 'Token refresh unauthorized.'); - } - - const desc = detail ?? `Token refresh failed (HTTP ${status}).`; - if (RETRYABLE_STATUSES.has(status)) { - lastError = new RetryableRefreshError(desc); - if (attempt < maxRetries - 1) { - await sleep(backoff(attempt)); - continue; - } - // fall through: out of retries, surface the retryable error - } else { - throw new OAuthError(desc); - } - } - - throw lastError ?? new OAuthError('Token refresh failed after retries.'); -} - -export type { DeviceHeaders }; diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index 3edb08be..61b90a61 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -1,15 +1,83 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; -import { parseSupportsThinkingType } from './managed-kimi-code'; -import type { - ManagedKimiCodeModelInfo, - ManagedKimiConfigShape, -} from './managed-kimi-code'; - -export type { ManagedKimiConfigShape }; export type LoginPlatformProviderType = 'pythinker' | 'openai' | 'anthropic' | 'openai_responses'; +/** + * Server-declared thinking toggle support from `/models`: + * - 'only' — thinking cannot be turned off (always-thinking) + * - 'no' — thinking is not supported at all + * - 'both' — thinking can be toggled on and off + * Absent on older servers — callers fall back to `supportsReasoning`. + */ +export type SupportsThinkingType = 'only' | 'no' | 'both'; + +/** + * A model as a login platform describes it, normalized from the platform's + * `/models` response (snake_case on the wire) or from a models.dev catalog + * entry. Shared by every platform: API-key platforms, the OpenAI Codex OAuth + * login, and the custom api.json registry. + */ +export interface PlatformModelInfo { + readonly id: string; + readonly contextLength: number; + readonly supportsReasoning: boolean; + readonly supportedReasoningEfforts?: readonly string[]; + readonly supportsImageIn: boolean; + readonly supportsVideoIn: boolean; + readonly supportsToolUse?: boolean; + readonly supportsFastMode?: boolean; + readonly supportsThinkingType?: SupportsThinkingType; + readonly displayName?: string | undefined; +} + +export interface PlatformProviderConfig { + type: LoginPlatformProviderType; + baseUrl?: string | undefined; + apiKey?: string | undefined; + readonly [key: string]: unknown; +} + +export interface PlatformModelAlias { + provider: string; + model: string; + maxContextSize: number; + capabilities?: string[] | undefined; + supportEfforts?: readonly string[]; + displayName?: string | undefined; + readonly [key: string]: unknown; +} + +/** + * The slice of the on-disk config a login writes. Structural rather than + * imported from `agent-core` so this package stays dependency-free. + */ +export interface PlatformConfigShape { + providers: Record>; + models?: Record> | undefined; + defaultModel?: string | undefined; + defaultThinking?: boolean | undefined; + thinking?: { + mode?: 'auto' | 'on' | 'off'; + effort?: string; + }; + [key: string]: unknown; +} + +// Unknown or missing values resolve to undefined so callers fall back to the +// legacy supports_reasoning boolean instead of guessing. +export function parseSupportsThinkingType(value: unknown): SupportsThinkingType | undefined { + return value === 'only' || value === 'no' || value === 'both' ? value : undefined; +} + +// Unknown or missing values resolve to undefined so the field is simply absent +// for older servers instead of being guessed. +function parseSupportedReasoningEfforts(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) return undefined; + const efforts = value.filter((effort): effort is string => typeof effort === 'string'); + return efforts.length > 0 ? efforts : undefined; +} + /** * A platform the user can add with an API key. Models are fetched from the * remote `/models` endpoint at `baseUrl`, or from a models.dev catalog when @@ -104,7 +172,7 @@ export function isOpenPlatformId(id: string): boolean { function toModelInfo( item: unknown, platform: OpenPlatformDefinition, -): ManagedKimiCodeModelInfo | undefined { +): PlatformModelInfo | undefined { if (!isRecord(item) || typeof item['id'] !== 'string' || item['id'].length === 0) { return undefined; } @@ -131,6 +199,7 @@ function toModelInfo( supportsToolUse, supportsFastMode: Boolean(item['supports_fast_mode']), supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']), + supportedReasoningEfforts: parseSupportedReasoningEfforts(item['supported_reasoning_efforts']), displayName: normalizedDisplayName, }; } @@ -139,7 +208,7 @@ function toModelInfo( * Derives kosong capability strings from a model info entry; undefined when * the model declares no capabilities. */ -export function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undefined { +export function capabilitiesForModel(model: PlatformModelInfo): string[] | undefined { const caps = new Set(); // supports_thinking_type is the full three-state declaration and wins over // the legacy supports_reasoning boolean; absent (older servers) falls back. @@ -185,7 +254,7 @@ export async function fetchOpenPlatformModels( apiKey: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal, -): Promise { +): Promise { const baseUrl = platform.baseUrl; if (baseUrl === undefined || baseUrl.length === 0) { throw new Error(`Platform "${platform.id}" has no baseUrl for remote model listing.`); @@ -209,7 +278,7 @@ export async function fetchOpenPlatformModels( } return payload['data'] .map((item) => toModelInfo(item, platform)) - .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); + .filter((item): item is PlatformModelInfo => item !== undefined); } /** @@ -217,9 +286,9 @@ export async function fetchOpenPlatformModels( * prefixes; returns the full list when no prefixes are configured. */ export function filterModelsByPrefix( - models: ManagedKimiCodeModelInfo[], + models: PlatformModelInfo[], platform: OpenPlatformDefinition, -): ManagedKimiCodeModelInfo[] { +): PlatformModelInfo[] { if (!platform.allowedPrefixes || platform.allowedPrefixes.length === 0) { return models; } @@ -238,11 +307,11 @@ export interface ApplyOpenPlatformResult { * default model to the selected one. */ export function applyOpenPlatformConfig( - config: ManagedKimiConfigShape, + config: PlatformConfigShape, options: { readonly platform: OpenPlatformDefinition; - readonly models: readonly ManagedKimiCodeModelInfo[]; - readonly selectedModel: ManagedKimiCodeModelInfo; + readonly models: readonly PlatformModelInfo[]; + readonly selectedModel: PlatformModelInfo; readonly thinking: boolean; /** The effort level the user picked; only the on/off bit persists without it. */ readonly effort?: string; @@ -295,7 +364,7 @@ export function applyOpenPlatformConfig( * that pointed at them. */ export function removeOpenPlatformConfig( - config: ManagedKimiConfigShape, + config: PlatformConfigShape, platformId: string, ): void { delete config.providers[platformId]; diff --git a/packages/oauth/src/openai-codex-oauth.ts b/packages/oauth/src/openai-codex-oauth.ts index 3c435e0b..668f7d1c 100644 --- a/packages/oauth/src/openai-codex-oauth.ts +++ b/packages/oauth/src/openai-codex-oauth.ts @@ -2,13 +2,13 @@ import { createHash, randomBytes } from 'node:crypto'; import { createServer, type Server } from 'node:http'; import { readApiErrorMessage } from './api-error'; -import type { - ManagedKimiCodeModelInfo, - ManagedKimiConfigShape, -} from './managed-kimi-code'; -import { parseSupportsThinkingType } from './managed-kimi-code'; import { renderOAuthErrorPage, renderOpenAICodexOAuthSuccessPage } from './oauth-pages'; -import { capabilitiesForModel } from './open-platform'; +import { + capabilitiesForModel, + parseSupportsThinkingType, + type PlatformConfigShape, + type PlatformModelInfo, +} from './open-platform'; import { isRecord } from './utils'; export const OPENAI_CODEX_OAUTH_PLATFORM_ID = 'openai-codex-oauth'; @@ -450,7 +450,7 @@ function readCodexFastMode(item: Record): boolean { }); } -function toCodexModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { +function toCodexModelInfo(item: unknown): PlatformModelInfo | undefined { if (!isRecord(item)) return undefined; const id = @@ -514,7 +514,7 @@ function toCodexModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { }; } -function parseCodexModelsPayload(payload: unknown): ManagedKimiCodeModelInfo[] { +function parseCodexModelsPayload(payload: unknown): PlatformModelInfo[] { if (!isRecord(payload)) { throw new Error(`Unexpected models response for ${CODEX_BASE_URL}.`); } @@ -530,7 +530,7 @@ function parseCodexModelsPayload(payload: unknown): ManagedKimiCodeModelInfo[] { return rawModels .map((item) => toCodexModelInfo(item)) - .filter((item): item is ManagedKimiCodeModelInfo => item !== undefined); + .filter((item): item is PlatformModelInfo => item !== undefined); } /** @@ -539,7 +539,7 @@ function parseCodexModelsPayload(payload: unknown): ManagedKimiCodeModelInfo[] { */ export async function fetchOpenAICodexModels( options: FetchOpenAICodexModelsOptions, -): Promise { +): Promise { const fetchImpl = options.fetchImpl ?? fetch; const modelsUrl = `${CODEX_BASE_URL}/models?client_version=${encodeURIComponent(OPENAI_CODEX_CLI_CLIENT_VERSION)}`; const response = await fetchImpl(modelsUrl, { @@ -573,13 +573,13 @@ export interface ApplyOpenAICodexOAuthResult { * reasoning effort into the config in place. */ export function applyOpenAICodexOAuthConfig( - config: ManagedKimiConfigShape, + config: PlatformConfigShape, options: { readonly accessToken: string; readonly accountId?: string | undefined; readonly refreshToken?: string | undefined; - readonly models: readonly ManagedKimiCodeModelInfo[]; - readonly selectedModel: ManagedKimiCodeModelInfo; + readonly models: readonly PlatformModelInfo[]; + readonly selectedModel: PlatformModelInfo; readonly thinking?: boolean | undefined; /** * The effort level the user picked. Omitted, the model's top supported diff --git a/packages/oauth/src/storage.ts b/packages/oauth/src/storage.ts deleted file mode 100644 index 274b4d3a..00000000 --- a/packages/oauth/src/storage.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * File-based OAuth token storage. - * - * Tokens are persisted under a directory (default - * `~/.pythinker-code/credentials/`) as `.json` with mode 0600 (parent - * dir 0700). Wire format uses snake_case to match the server contract. - * - * Write semantics: write to `.tmp..` → fsync → rename. - * Atomic on POSIX; Windows best-effort. - * - * Load semantics: missing file → undefined. Corrupt JSON / wrong shape → - * undefined (never throws). Callers treat undefined as "no token stored". - */ - -import { randomBytes } from 'node:crypto'; -import { - chmodSync, - closeSync, - fsyncSync, - mkdirSync, - openSync, - readdirSync, - readFileSync, - renameSync, - unlinkSync, - writeSync, -} from 'node:fs'; -import { basename, join } from 'node:path'; - -import type { TokenInfo, TokenInfoWire } from './types'; -import { tokenFromWire, tokenToWire } from './types'; -import { isRecord } from './utils'; - -export interface TokenStorage { - load(name: string): Promise; - save(name: string, token: TokenInfo): Promise; - remove(name: string): Promise; - list(): Promise; -} - -export class FileTokenStorage implements TokenStorage { - private readonly dir: string; - - constructor(dir: string) { - this.dir = dir; - } - - private ensureDir(): void { - mkdirSync(this.dir, { recursive: true, mode: 0o700 }); - // recursive=true with mode only applies on initial create; tighten after - // the fact in case an existing dir had looser permissions. - try { - chmodSync(this.dir, 0o700); - } catch { - // best-effort; Windows / read-only FS may refuse - } - } - - private pathFor(name: string): string { - // Guard against path traversal: caller-provided names (from config.toml - // or slash commands) must not escape the credentials dir. `basename` - // strips any `..` or `/` segments; if the sanitized value differs from - // the input we refuse the request entirely rather than silently - // writing to a different file than the caller asked for. - const safe = basename(name); - if (safe.length === 0 || safe !== name || safe.startsWith('.')) { - throw new Error(`Invalid token name: "${name}"`); - } - return join(this.dir, `${safe}.json`); - } - - async load(name: string): Promise { - const file = this.pathFor(name); - let raw: string; - try { - raw = readFileSync(file, 'utf-8'); - } catch { - return undefined; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return undefined; - } - if (!isRecord(parsed)) return undefined; - return tokenFromWire(parsed as Partial); - } - - async save(name: string, token: TokenInfo): Promise { - this.ensureDir(); - const target = this.pathFor(name); - const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`; - const data = Buffer.from(`${JSON.stringify(tokenToWire(token), null, 2)}\n`, 'utf-8'); - const fd = openSync(tmp, 'w', 0o600); - try { - let written = 0; - while (written < data.length) { - written += writeSync(fd, data, written, data.length - written); - } - fsyncSync(fd); - } finally { - closeSync(fd); - } - try { - // chmod again in case umask stripped bits during open - chmodSync(tmp, 0o600); - renameSync(tmp, target); - } catch (error) { - try { - unlinkSync(tmp); - } catch { - /* ignore */ - } - throw error; - } - } - - async remove(name: string): Promise { - try { - unlinkSync(this.pathFor(name)); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } - } - - async list(): Promise { - let entries: string[]; - try { - entries = readdirSync(this.dir); - } catch { - return []; - } - return entries.filter((e) => e.endsWith('.json')).map((e) => e.slice(0, -'.json'.length)); - } -} diff --git a/packages/oauth/src/token-state.ts b/packages/oauth/src/token-state.ts deleted file mode 100644 index 5785d4e4..00000000 --- a/packages/oauth/src/token-state.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Internal three-state view of what storage holds for a provider. - * - * • valid — a usable token. Refresh decisions are made elsewhere - * from `token.expiresAt`. - * • revoked — a "tombstone": the on-disk file exists but the prior - * refresh_token was rejected (401/403). A fresh process - * with no in-memory state needs to see "previously logged - * in, now needs re-login" instead of "never logged in". - * • missing — no file on disk. - * - * Wire format and `TokenInfo` are unchanged: a revoked record is still - * persisted as `{ access_token: "", refresh_token: "", expires_at: 0, - * scope, token_type, expires_in: 0 }`. This module exists so the - * manager doesn't have to repeat that field-emptiness convention on - * every branch. - * - * Package-private. NOT re-exported from `index.ts`. - */ - -import type { TokenInfo } from './types'; - -export type TokenState = - | { readonly kind: 'valid'; readonly token: TokenInfo } - | { readonly kind: 'revoked'; readonly scope: string; readonly tokenType: string } - | { readonly kind: 'missing' }; - -export function classifyToken(token: TokenInfo | undefined): TokenState { - if (token === undefined) return { kind: 'missing' }; - if (token.accessToken.length === 0) { - return { kind: 'revoked', scope: token.scope, tokenType: token.tokenType }; - } - return { kind: 'valid', token }; -} - -export function revokedTombstone(prior: TokenInfo): TokenInfo { - return { - accessToken: '', - refreshToken: '', - expiresAt: 0, - scope: prior.scope, - tokenType: prior.tokenType, - expiresIn: 0, - }; -} diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts deleted file mode 100644 index 2eec1f62..00000000 --- a/packages/oauth/src/toolkit.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import { KIMI_CODE_FLOW_CONFIG } from './constants'; -import { OAuthUnauthorizedError } from './errors'; -import { assertPythinkerHostIdentity, createPythinkerDeviceHeaders, type PythinkerHostIdentity } from './identity'; -import { - fetchSubmitFeedback, - kimiCodeFeedbackUrl, - type FetchSubmitFeedbackResult, - type SubmitFeedbackBody, -} from './managed-feedback'; -import { - KIMI_CODE_OAUTH_KEY, - KIMI_CODE_PROVIDER_NAME, - provisionManagedKimiCodeConfig, - resolveKimiCodeOAuthKey, - type ManagedKimiCodeProvisionResult, - type ManagedKimiConfigAdapter, -} from './managed-kimi-code'; -import { - fetchManagedUsage, - kimiCodeUsageUrl, - type FetchManagedUsageError, - type ParsedManagedUsage, -} from './managed-usage'; -import { OAuthManager, type LoginOptions, type OAuthManagerOptions } from './oauth-manager'; -import { FileTokenStorage, type TokenStorage } from './storage'; -import type { OAuthFlowConfig } from './types'; - -export interface BearerTokenProvider { - getAccessToken(options?: { readonly force?: boolean | undefined }): Promise; -} - -export interface AuthProviderStatus { - readonly providerName: string; - readonly hasToken: boolean; -} - -export interface AuthStatus { - readonly providers: readonly AuthProviderStatus[]; -} - -export interface PythinkerOAuthToolkitOptions { - readonly identity?: PythinkerHostIdentity | undefined; - readonly homeDir?: string | undefined; - readonly credentialsDir?: string | undefined; - readonly storage?: TokenStorage | undefined; - readonly flowConfig?: OAuthFlowConfig | undefined; - readonly configAdapter?: ManagedKimiConfigAdapter | undefined; - readonly fetchImpl?: typeof fetch | undefined; - readonly now?: OAuthManagerOptions['now']; - readonly sleep?: OAuthManagerOptions['sleep']; - readonly deviceCodeTimeoutMs?: number | undefined; - readonly refreshThreshold?: OAuthManagerOptions['refreshThreshold']; - readonly onRefresh?: OAuthManagerOptions['onRefresh']; -} - -export interface PythinkerOAuthLoginOptions extends LoginOptions { - readonly provisionConfig?: boolean | undefined; - readonly baseUrl?: string | undefined; - readonly oauthRef?: PythinkerOAuthTokenRef | undefined; - readonly oauthHost?: string | undefined; -} - -export interface PythinkerOAuthTokenRef { - readonly key?: string | undefined; - readonly oauthHost?: string | undefined; -} - -export interface PythinkerOAuthLoginResult { - readonly providerName: string; - readonly ok: true; - readonly provision?: ManagedKimiCodeProvisionResult | undefined; -} - -export interface PythinkerOAuthLogoutResult { - readonly providerName: string; - readonly ok: true; -} - -export type AuthManagedUsageResult = - | { - readonly kind: 'ok'; - readonly summary: ParsedManagedUsage['summary']; - readonly limits: ParsedManagedUsage['limits']; - } - | FetchManagedUsageError; - -export class PythinkerOAuthToolkit { - private readonly homeDir: string; - private readonly identity: PythinkerHostIdentity | undefined; - private readonly storage: TokenStorage; - private readonly flowConfig: OAuthFlowConfig; - private readonly configAdapter: ManagedKimiConfigAdapter | undefined; - private readonly fetchImpl: typeof fetch | undefined; - private readonly managerOptions: Pick< - OAuthManagerOptions, - 'now' | 'sleep' | 'deviceCodeTimeoutMs' | 'refreshThreshold' | 'onRefresh' - >; - private readonly managers = new Map(); - - constructor(options: PythinkerOAuthToolkitOptions) { - this.identity = - options.identity === undefined ? undefined : assertPythinkerHostIdentity(options.identity); - this.homeDir = options.homeDir ?? defaultPythinkerHome(); - const credentialsDir = options.credentialsDir ?? join(this.homeDir, 'credentials'); - this.storage = options.storage ?? new FileTokenStorage(credentialsDir); - this.flowConfig = options.flowConfig ?? KIMI_CODE_FLOW_CONFIG; - this.configAdapter = options.configAdapter; - this.fetchImpl = options.fetchImpl; - this.managerOptions = { - now: options.now, - sleep: options.sleep, - deviceCodeTimeoutMs: options.deviceCodeTimeoutMs, - refreshThreshold: options.refreshThreshold, - onRefresh: options.onRefresh, - }; - } - - async status( - providerName?: string | undefined, - oauthRef?: PythinkerOAuthTokenRef | undefined, - ): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const oauthHost = this.oauthHostFor(oauthRef); - const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); - return { - providers: [ - { - providerName: name, - hasToken: await this.managerFor(name, oauthKey, oauthHost).hasToken(), - }, - ], - }; - } - - async login( - providerName?: string | undefined, - options: PythinkerOAuthLoginOptions = {}, - ): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const oauthHost = this.oauthHostFor(options.oauthRef, options.oauthHost); - const oauthKey = options.oauthRef?.key ?? this.defaultOAuthKey(options.baseUrl, oauthHost); - const manager = this.managerFor(name, oauthKey, oauthHost); - const hadToken = await manager.hasToken(); - let usedDeviceLogin = false; - const loginWithDevice = async (): Promise => { - usedDeviceLogin = true; - return ( - await manager.login({ - signal: options.signal, - onDeviceCode: options.onDeviceCode, - }) - ).accessToken; - }; - let accessToken: string; - if (hadToken) { - try { - accessToken = await manager.ensureFresh(); - } catch (error) { - if (!(error instanceof OAuthUnauthorizedError)) throw error; - accessToken = await loginWithDevice(); - } - } else { - accessToken = await loginWithDevice(); - } - - const shouldProvision = options.provisionConfig ?? this.configAdapter !== undefined; - const configAdapter = this.configAdapter; - let provision: ManagedKimiCodeProvisionResult | undefined; - if (shouldProvision && configAdapter !== undefined) { - const provisionWithToken = (token: string): Promise => - provisionManagedKimiCodeConfig({ - accessToken: token, - adapter: configAdapter, - baseUrl: options.baseUrl, - oauthKey, - oauthHost, - preserveDefaultModel: true, - fetchImpl: this.fetchImpl, - }); - try { - provision = await provisionWithToken(accessToken); - } catch (error) { - if (!(error instanceof OAuthUnauthorizedError) || !hadToken || usedDeviceLogin) { - throw error; - } - let retryToken: string; - try { - retryToken = await manager.ensureFresh({ force: true }); - } catch (refreshError) { - if (!(refreshError instanceof OAuthUnauthorizedError)) throw refreshError; - retryToken = await loginWithDevice(); - } - try { - provision = await provisionWithToken(retryToken); - } catch (retryError) { - if (!(retryError instanceof OAuthUnauthorizedError) || usedDeviceLogin) { - throw retryError; - } - provision = await provisionWithToken(await loginWithDevice()); - } - } - } - - return { providerName: name, ok: true, provision }; - } - - async logout( - providerName?: string | undefined, - oauthRef?: PythinkerOAuthTokenRef | undefined, - ): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const oauthHost = this.oauthHostFor(oauthRef); - const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); - await this.managerFor(name, oauthKey, oauthHost).logout(); - if (this.configAdapter?.remove !== undefined && name === KIMI_CODE_PROVIDER_NAME) { - const config = await this.configAdapter.read(); - this.configAdapter.remove(config); - await this.configAdapter.write(config); - } - return { providerName: name, ok: true }; - } - - async ensureFresh( - providerName?: string | undefined, - options: { - readonly force?: boolean | undefined; - readonly oauthRef?: PythinkerOAuthTokenRef | undefined; - } = {}, - ): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const oauthHost = this.oauthHostFor(options.oauthRef); - const oauthKey = options.oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); - return this.managerFor(name, oauthKey, oauthHost).ensureFresh(options); - } - - async getCachedAccessToken( - providerName?: string, - oauthRef?: PythinkerOAuthTokenRef, - ): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const oauthHost = this.oauthHostFor(oauthRef); - const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); - return this.managerFor(name, oauthKey, oauthHost).getCachedAccessToken(); - } - - tokenProvider( - providerName?: string | undefined, - oauthRef?: PythinkerOAuthTokenRef | undefined, - ): BearerTokenProvider { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - const oauthHost = this.oauthHostFor(oauthRef); - const oauthKey = oauthRef?.key ?? this.defaultOAuthKey(undefined, oauthHost); - return { - getAccessToken: (options) => this.managerFor(name, oauthKey, oauthHost).ensureFresh(options), - }; - } - - async getManagedUsage( - providerName?: string | undefined, - options: { - readonly oauthRef?: PythinkerOAuthTokenRef | undefined; - readonly baseUrl?: string | undefined; - } = {}, - ): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - try { - const accessToken = await this.ensureFresh(name, { - oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), - }); - const result = await fetchManagedUsage(managedUsageUrl(options.baseUrl), accessToken); - if (result.kind === 'error') return result; - return { - kind: 'ok', - summary: result.parsed.summary, - limits: result.parsed.limits, - }; - } catch (error) { - return { - kind: 'error', - message: error instanceof Error ? error.message : String(error), - }; - } - } - - async submitFeedback( - body: SubmitFeedbackBody, - providerName?: string | undefined, - options: { - readonly oauthRef?: PythinkerOAuthTokenRef | undefined; - readonly baseUrl?: string | undefined; - } = {}, - ): Promise { - const name = providerName ?? KIMI_CODE_PROVIDER_NAME; - try { - const accessToken = await this.ensureFresh(name, { - oauthRef: options.oauthRef ?? this.defaultOAuthRef(options.baseUrl), - }); - return await fetchSubmitFeedback(managedFeedbackUrl(options.baseUrl), accessToken, body); - } catch (error) { - return { - kind: 'error', - message: error instanceof Error ? error.message : String(error), - }; - } - } - - managerFor( - providerName: string, - oauthKey = KIMI_CODE_OAUTH_KEY, - oauthHost?: string | undefined, - ): OAuthManager { - const storageName = resolvePythinkerTokenStorageName({ providerName, oauthKey }); - const effectiveOAuthHost = oauthHost ?? this.flowConfig.oauthHost; - const managerKey = `${storageName}\0${normalizeOAuthHost(effectiveOAuthHost)}`; - let manager = this.managers.get(managerKey); - if (manager !== undefined) return manager; - - const identity = this.identity; - manager = new OAuthManager({ - config: { - ...this.flowConfig, - oauthHost: effectiveOAuthHost, - name: storageName, - }, - storage: this.storage, - configDir: this.homeDir, - deviceHeaders: - identity === undefined - ? undefined - : () => - createPythinkerDeviceHeaders({ - homeDir: this.homeDir, - version: identity.version, - }), - ...this.managerOptions, - }); - this.managers.set(managerKey, manager); - return manager; - } - - private defaultOAuthKey( - baseUrl?: string | undefined, - oauthHost?: string | undefined, - ): string { - return resolveKimiCodeOAuthKey({ - oauthHost: oauthHost ?? this.flowConfig.oauthHost, - baseUrl, - }); - } - - private defaultOAuthRef(baseUrl?: string | undefined): PythinkerOAuthTokenRef { - return { - key: this.defaultOAuthKey(baseUrl, this.flowConfig.oauthHost), - oauthHost: this.flowConfig.oauthHost, - }; - } - - private oauthHostFor( - oauthRef?: PythinkerOAuthTokenRef | undefined, - oauthHost?: string | undefined, - ): string { - return oauthRef?.oauthHost ?? oauthHost ?? this.flowConfig.oauthHost; - } -} - -export function resolvePythinkerTokenStorageName(input: { - readonly providerName?: string | undefined; - readonly oauthKey?: string | undefined; -}): string { - const providerName = input.providerName ?? KIMI_CODE_PROVIDER_NAME; - if (providerName !== KIMI_CODE_PROVIDER_NAME) { - throw new Error(`No OAuth manager configured for provider "${providerName}".`); - } - - const key = input.oauthKey ?? KIMI_CODE_OAUTH_KEY; - if (key === 'kimi-code' || key === KIMI_CODE_OAUTH_KEY) return 'kimi-code'; - - const prefix = 'oauth/'; - if (key.startsWith(prefix) && key.slice(prefix.length).length > 0) { - return key.slice(prefix.length); - } - - if (!key.includes('/') && !key.startsWith('.')) return key; - throw new Error(`Invalid Pythinker OAuth token key: "${key}".`); -} - -function defaultPythinkerHome(): string { - const override = process.env['PYTHINKER_CODE_HOME']; - if (override !== undefined && override.length > 0) return override; - return join(homedir(), '.pythinker-code'); -} - -function managedUsageUrl(baseUrl: string | undefined): string { - if (baseUrl === undefined) return kimiCodeUsageUrl(); - return `${baseUrl.replace(/\/+$/, '')}/usages`; -} - -function managedFeedbackUrl(baseUrl: string | undefined): string { - if (baseUrl === undefined) return kimiCodeFeedbackUrl(); - return `${baseUrl.replace(/\/+$/, '')}/feedback`; -} - -function normalizeOAuthHost(oauthHost: string): string { - return oauthHost.trim().replace(/\/+$/, ''); -} diff --git a/packages/oauth/src/types.ts b/packages/oauth/src/types.ts deleted file mode 100644 index c370c5d2..00000000 --- a/packages/oauth/src/types.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * OAuth type definitions for managed providers. - * - * Only Device Code Flow (RFC 8628) is supported, against - * `https://auth.pythinker.com`. - * - * Wire format (on disk / server) uses snake_case to match the server - * contract; in-process types use camelCase per TS convention. - */ - -export type OAuthStorageBackend = 'file'; - -/** A persisted OAuth token bundle. */ -export interface TokenInfo { - readonly accessToken: string; - readonly refreshToken: string; - /** Unix seconds when access_token expires. */ - readonly expiresAt: number; - readonly scope: string; - readonly tokenType: string; - /** Original expires_in from server response (seconds). */ - readonly expiresIn: number; -} - -/** RFC 8628 §3.2 device authorization response. */ -export interface DeviceAuthorization { - readonly userCode: string; - readonly deviceCode: string; - readonly verificationUri: string; - readonly verificationUriComplete: string; - /** Seconds until device_code expires (server-reported). May be null. */ - readonly expiresIn: number | null; - /** Polling interval in seconds. */ - readonly interval: number; -} - -/** OAuth flow endpoint + client configuration. */ -export interface OAuthFlowConfig { - /** Logical provider name for storage (e.g. "kimi-code"). */ - readonly name: string; - /** Base URL of the OAuth server, no trailing slash. */ - readonly oauthHost: string; - /** Client ID registered with the OAuth provider. */ - readonly clientId: string; -} - -/** Device identification for `X-Msh-*` headers. */ -export interface DeviceHeaders { - readonly 'X-Msh-Platform': string; - readonly 'X-Msh-Version': string; - readonly 'X-Msh-Device-Name': string; - readonly 'X-Msh-Device-Model': string; - readonly 'X-Msh-Os-Version': string; - readonly 'X-Msh-Device-Id': string; -} - -/** JSON wire format for token persistence (snake_case, Python-compatible). */ -export interface TokenInfoWire { - readonly access_token: string; - readonly refresh_token: string; - readonly expires_at: number; - readonly scope: string; - readonly token_type: string; - readonly expires_in: number; -} - -export function tokenToWire(token: TokenInfo): TokenInfoWire { - return { - access_token: token.accessToken, - refresh_token: token.refreshToken, - expires_at: token.expiresAt, - scope: token.scope, - token_type: token.tokenType, - expires_in: token.expiresIn, - }; -} - -export function tokenFromWire(wire: Partial): TokenInfo { - return { - accessToken: wire.access_token ?? '', - refreshToken: wire.refresh_token ?? '', - expiresAt: typeof wire.expires_at === 'number' ? wire.expires_at : 0, - scope: wire.scope ?? '', - tokenType: wire.token_type ?? '', - expiresIn: typeof wire.expires_in === 'number' ? wire.expires_in : 0, - }; -} diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index d894fd9e..2247189c 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -11,8 +11,8 @@ import { removeCustomRegistryProvider, type CustomRegistryProviderEntry, type CustomRegistrySource, - type ManagedKimiConfigShape, } from '../src/custom-registry'; +import type { PlatformConfigShape } from '../src/open-platform'; function makeKokubResponseBody(): Record { return { @@ -184,7 +184,7 @@ describe('fetchCustomRegistry', () => { describe('applyCustomRegistryProvider', () => { it('writes provider + model aliases for a kokub-shaped entry with default fallbacks', () => { - const config: ManagedKimiConfigShape = { providers: {} }; + const config: PlatformConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'registry_chat-completions', name: 'Sample Registry (chat completions)', @@ -224,7 +224,7 @@ describe('applyCustomRegistryProvider', () => { }); it('falls back to the model id for displayName when name is absent', () => { - const config: ManagedKimiConfigShape = { providers: {} }; + const config: PlatformConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'demo', name: 'Demo', @@ -245,7 +245,7 @@ describe('applyCustomRegistryProvider', () => { }); it('derives rich capabilities and limit-based context size when rich fields are present', () => { - const config: ManagedKimiConfigShape = { providers: {} }; + const config: PlatformConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'rich', name: 'Rich Provider', @@ -279,7 +279,7 @@ describe('applyCustomRegistryProvider', () => { }); it('clears stale aliases for the same provider before re-populating', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -323,7 +323,7 @@ describe('applyCustomRegistryProvider', () => { describe('removeCustomRegistryProvider', () => { it('removes the provider and every alias for it, and clears matching defaultModel', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -359,7 +359,7 @@ describe('removeCustomRegistryProvider', () => { }); it('leaves defaultModel intact when it belongs to another provider', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -401,7 +401,7 @@ describe('applyCustomRegistryEntries', () => { c: { id: 'c', name: 'C', api: 'https://c.test/v1', type: 'openai', models: { 'm1': { id: 'm1' } } }, }; - const config: ManagedKimiConfigShape = { providers: {} }; + const config: PlatformConfigShape = { providers: {} }; applyCustomRegistryEntries(config, entries, source); applyCustomRegistryEntries(config, entries, source); @@ -417,7 +417,7 @@ describe('applyCustomRegistryEntries', () => { url: 'https://registry.example.test/api.json', apiKey: 'sk-new', }; - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, }, @@ -453,7 +453,7 @@ describe('applyCustomRegistryEntries', () => { url: 'https://registry.example.test/api.json', apiKey: 'sk-new', }; - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, }, @@ -507,7 +507,7 @@ describe('applyCustomRegistryEntries', () => { b: { id: 'b', name: 'B', api: 'https://b.test/v1', type: 'openai', models: { m1: { id: 'm1' } } }, }; - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { // Provider from an unrelated source — must not be touched. keepme: { @@ -564,7 +564,7 @@ describe('applyCustomRegistryEntries', () => { apiKey: 'sk-b', }; - const config: ManagedKimiConfigShape = { providers: {} }; + const config: PlatformConfigShape = { providers: {} }; applyCustomRegistryEntries( config, { diff --git a/packages/oauth/test/managed-feedback.test.ts b/packages/oauth/test/managed-feedback.test.ts deleted file mode 100644 index ca61ee36..00000000 --- a/packages/oauth/test/managed-feedback.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { - fetchSubmitFeedback, - kimiCodeFeedbackUrl, - type SubmitFeedbackBody, -} from '../src/managed-feedback'; - -afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); -}); - -const SAMPLE_BODY: SubmitFeedbackBody = { - session_id: 'sess-123', - content: 'great tool', - version: 'pythinker-code-0.1.1', - os: 'Darwin 25.3.0', - model: 'pythinker-code/pythinker-for-coding', -}; - -describe('kimiCodeFeedbackUrl', () => { - it('appends /feedback to the default base URL', () => { - expect(kimiCodeFeedbackUrl()).toBe('https://api.kimi.com/coding/v1/feedback'); - }); - - it('honours PYTHINKER_CODE_BASE_URL and trims trailing slashes', () => { - vi.stubEnv('PYTHINKER_CODE_BASE_URL', 'https://example.test/v9///'); - expect(kimiCodeFeedbackUrl()).toBe('https://example.test/v9/feedback'); - }); -}); - -describe('fetchSubmitFeedback', () => { - it('POSTs JSON body with bearer auth and returns ok on 200', async () => { - const fetchMock = vi.fn(async () => new Response('', { status: 200 })); - vi.stubGlobal('fetch', fetchMock); - - const result = await fetchSubmitFeedback( - 'https://api.example/feedback', - 'access-token', - SAMPLE_BODY, - ); - - expect(result).toEqual({ kind: 'ok' }); - - const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; - const [calledUrl, init] = calls[0]!; - expect(calledUrl).toBe('https://api.example/feedback'); - expect(init?.method).toBe('POST'); - - const headers = new Headers((init?.headers ?? {}) as Record); - expect(headers.get('authorization')).toBe('Bearer access-token'); - expect(headers.get('content-type')).toBe('application/json'); - expect(headers.get('accept')).toBe('application/json'); - - expect(JSON.parse(init?.body as string)).toEqual(SAMPLE_BODY); - }); - - it('preserves the pythinker-code- version prefix in the request body', async () => { - const fetchMock = vi.fn(async () => new Response('', { status: 200 })); - vi.stubGlobal('fetch', fetchMock); - - await fetchSubmitFeedback('https://api.example/feedback', 'tok', SAMPLE_BODY); - - const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; - const sent = JSON.parse(calls[0]?.[1]?.body as string) as Record; - expect(sent['version']).toBe('pythinker-code-0.1.1'); - }); - - it('returns an error with status when the server responds 401', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('', { status: 401 })), - ); - - const result = await fetchSubmitFeedback( - 'https://api.example/feedback', - 'access-token', - SAMPLE_BODY, - ); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBe(401); - expect(result.message).toMatch(/401/); - }); - - it('surfaces API error messages from failed submissions', async () => { - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ error: { message: 'feedback rejected' } }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }), - ), - ); - - const result = await fetchSubmitFeedback( - 'https://api.example/feedback', - 'access-token', - SAMPLE_BODY, - ); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBe(400); - expect(result.message).toBe('feedback rejected'); - }); - - it('returns an error with status when the server responds 500', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('', { status: 500 })), - ); - - const result = await fetchSubmitFeedback( - 'https://api.example/feedback', - 'access-token', - SAMPLE_BODY, - ); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBe(500); - expect(result.message).toBe('Failed to submit feedback: HTTP 500'); - }); - - it('returns a timeout error when the request aborts', async () => { - vi.stubGlobal( - 'fetch', - vi.fn( - (_url: string, init?: RequestInit) => - new Promise((_, reject) => { - init?.signal?.addEventListener('abort', () => { - const err = new Error('aborted'); - err.name = 'AbortError'; - reject(err); - }); - }), - ), - ); - - const result = await fetchSubmitFeedback( - 'https://api.example/feedback', - 'access-token', - SAMPLE_BODY, - { timeoutMs: 5 }, - ); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBeUndefined(); - expect(result.message).toMatch(/timed out/); - }); - - it('returns a generic error message on network failure', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new TypeError('network down'); - }), - ); - - const result = await fetchSubmitFeedback( - 'https://api.example/feedback', - 'access-token', - SAMPLE_BODY, - ); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBeUndefined(); - expect(result.message).toMatch(/network down/); - }); -}); diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts deleted file mode 100644 index b4abc784..00000000 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ /dev/null @@ -1,1120 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { - applyManagedKimiCodeLogoutConfig, - applyManagedKimiCodeConfig, - clearManagedKimiCodeConfig, - fetchManagedKimiCodeModels, - KIMI_CODE_OAUTH_KEY, - KIMI_CODE_PROVIDER_NAME, - ManagedKimiCodeModelsAuthError, - provisionManagedKimiCodeConfig, - resolveKimiCodeLoginAuth, - resolveKimiCodeOAuthKey, - resolveKimiCodeOAuthRef, - resolveKimiCodeRuntimeAuth, - type ManagedKimiConfigShape, -} from '../src/managed-kimi-code'; -import { OAuthUnauthorizedError } from '../src/errors'; - -function makeModelsResponse(): Response { - return new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - supports_image_in: true, - supports_video_in: true, - display_name: 'Pythinker for Coding', - }, - { - id: 'pythinker-k2.5', - context_length: 250000, - supports_reasoning: false, - supports_image_in: false, - supports_video_in: false, - supports_tool_use: false, - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); -} - -describe('provisionManagedKimiCodeConfig', () => { - it('keeps the legacy credential key for the default production environment', () => { - expect( - resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.kimi.com/', - baseUrl: 'https://api.kimi.com/coding/v1/', - }), - ).toBe(KIMI_CODE_OAUTH_KEY); - }); - - it('scopes credential keys for non-default OAuth hosts and API base URLs', () => { - const devKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - - expect(devKey).not.toBe(KIMI_CODE_OAUTH_KEY); - expect(devKey).toMatch(/^oauth\/kimi-code-env-[a-f0-9]{16}$/); - expect( - resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test/', - baseUrl: 'https://api.dev.example.test/coding/v1/', - }), - ).toBe(devKey); - }); - - it('derives a full OAuth ref whose key and persisted host stay in sync', () => { - // Default environment collapses to the legacy ref (no persisted host), so - // existing production credentials keep resolving to `kimi-code.json`. - expect( - resolveKimiCodeOAuthRef({ - oauthHost: 'https://auth.kimi.com/', - baseUrl: 'https://api.kimi.com/coding/v1/', - }), - ).toEqual({ storage: 'file', key: KIMI_CODE_OAUTH_KEY, oauthHost: undefined }); - - const defaultAuthCustomApiRef = resolveKimiCodeOAuthRef({ - baseUrl: 'https://api.example.test/coding/v1', - }); - expect(defaultAuthCustomApiRef).toEqual({ - storage: 'file', - key: resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.kimi.com', - baseUrl: 'https://api.example.test/coding/v1', - }), - oauthHost: 'https://auth.kimi.com', - }); - - // A non-default environment yields a scoped key AND the normalized host, - // both derived from the same input — login and runtime cannot drift apart. - const devRef = resolveKimiCodeOAuthRef({ - oauthHost: 'https://auth.dev.example.test/', - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - expect(devRef).toEqual({ - storage: 'file', - key: resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl: 'https://api.dev.example.test/coding/v1', - }), - oauthHost: 'https://auth.dev.example.test', - }); - }); - - it('resolves runtime auth from environment overrides over persisted config', () => { - 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 configuredOAuthRef = resolveKimiCodeOAuthRef({ - baseUrl: configuredBaseUrl, - }); - - const auth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl, - configuredOAuthRef, - env: { - PYTHINKER_CODE_BASE_URL: envBaseUrl, - PYTHINKER_CODE_OAUTH_HOST: envOauthHost, - }, - }); - - expect(auth.baseUrl).toBe('https://api.env.example.test/coding/v1'); - expect(auth.oauthRef).toEqual({ - storage: 'file', - key: resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.env.example.test', - baseUrl: 'https://api.env.example.test/coding/v1', - }), - oauthHost: 'https://auth.env.example.test', - }); - }); - - it('preserves a matching configured runtime OAuth ref when env is not overridden', () => { - const baseUrl = 'https://api.dev.example.test/coding/v1'; - const configuredOAuthRef = { - storage: 'keyring' as const, - key: resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl, - }), - oauthHost: 'https://auth.dev.example.test', - }; - - expect( - resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: baseUrl, - configuredOAuthRef, - env: {}, - }), - ).toEqual({ - baseUrl, - oauthRef: configuredOAuthRef, - }); - }); - - it('resolves login auth without reusing persisted refs under explicit or env overrides', () => { - const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; - const configuredOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: configuredBaseUrl }); - - expect( - resolveKimiCodeLoginAuth({ - configuredBaseUrl, - configuredOAuthRef, - requestedBaseUrl: 'https://api.requested.example.test/coding/v1/', - env: {}, - }), - ).toEqual({ - baseUrl: 'https://api.requested.example.test/coding/v1', - oauthHost: undefined, - }); - - expect( - resolveKimiCodeLoginAuth({ - configuredBaseUrl, - configuredOAuthRef, - env: {}, - }), - ).toEqual({ - baseUrl: configuredBaseUrl, - oauthHost: undefined, - oauthRef: configuredOAuthRef, - }); - }); - - it('writes the managed provider, models, services, and default model through an adapter', async () => { - const config: ManagedKimiConfigShape = { - providers: { - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - baseUrl: 'https://example.test/v1', - }, - }, - models: { - 'kimi-code/stale': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'stale', - }, - 'custom-default': { - provider: 'custom', - model: 'custom-model', - }, - }, - }; - const write = vi.fn(); - const fetchMock = vi.fn(async () => makeModelsResponse()); - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: fetchMock as unknown as typeof fetch, - adapter: { - configPath: '/tmp/config.toml', - read: () => config, - write, - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result).toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - defaultModel: 'kimi-code/pythinker-for-coding', - defaultThinking: true, - configPath: '/tmp/config.toml', - }); - expect(result.models[0]?.supportsToolUse).toBe(true); - expect(result.models[1]?.supportsToolUse).toBe(false); - expect(fetchMock).toHaveBeenCalledWith( - 'https://api.kimi.com/coding/v1/models', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer oauth-access-token', - Accept: 'application/json', - }), - }), - ); - const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; - const init = calls[0]?.[1] ?? {}; - const headers = new Headers((init.headers ?? {}) as Record); - expect(headers.get('user-agent')).toBeNull(); - expect(headers.get('x-msh-platform')).toBeNull(); - expect(write).toHaveBeenCalledWith(config); - - expect(config.providers['custom']).toMatchObject({ - apiKey: 'sk-existing', - }); - expect(config.models?.['custom-default']?.provider).toBe('custom'); - expect(config.models?.['kimi-code/stale']).toBeUndefined(); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - type: 'pythinker', - baseUrl: 'https://api.kimi.com/coding/v1', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }); - expect(config.models?.['kimi-code/pythinker-for-coding']).toMatchObject({ - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 262144, - capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'], - displayName: 'Pythinker for Coding', - }); - expect(config.models?.['kimi-code/pythinker-k2.5']?.capabilities).toBeUndefined(); - expect(config.services?.pythoughtsSearch).toMatchObject({ - baseUrl: 'https://api.kimi.com/coding/v1/search', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }); - expect(Object.keys(config.services ?? {})).toEqual(['pythoughtsSearch', 'pythoughtsFetch']); - }); - - it('writes scoped OAuth refs when provisioning against a non-default environment', async () => { - const config: ManagedKimiConfigShape = { - providers: {}, - }; - const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost: 'https://auth.dev.example.test', - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - - await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - baseUrl: 'https://api.dev.example.test/coding/v1', - oauthKey, - oauthHost: 'https://auth.dev.example.test', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - baseUrl: 'https://api.dev.example.test/coding/v1', - oauth: { - storage: 'file', - key: oauthKey, - oauthHost: 'https://auth.dev.example.test', - }, - }); - expect(config.services?.pythoughtsSearch?.oauth).toEqual({ - storage: 'file', - key: oauthKey, - oauthHost: 'https://auth.dev.example.test', - }); - expect(config.services?.pythoughtsFetch?.oauth).toEqual({ - storage: 'file', - key: oauthKey, - oauthHost: 'https://auth.dev.example.test', - }); - }); - - it('persists the default OAuth host when only the API base URL is scoped', async () => { - const config: ManagedKimiConfigShape = { - providers: {}, - }; - const baseUrl = 'https://api.example.test/coding/v1'; - const oauthKey = resolveKimiCodeOAuthKey({ baseUrl }); - - await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - baseUrl, - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({ - baseUrl, - oauth: { - storage: 'file', - key: oauthKey, - oauthHost: 'https://auth.kimi.com', - }, - }); - }); - - it('preserves an existing valid default model during refresh', async () => { - const config: ManagedKimiConfigShape = { - providers: { - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - baseUrl: 'https://example.test/v1', - }, - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - }, - }, - defaultModel: 'custom-default', - defaultThinking: false, - models: { - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 1000, - }, - 'kimi-code/stale': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'stale', - maxContextSize: 1000, - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('custom-default'); - expect(result.defaultThinking).toBe(false); - expect(config.defaultModel).toBe('custom-default'); - expect(config.defaultThinking).toBe(false); - expect(config.models?.['kimi-code/stale']).toBeUndefined(); - expect(config.models?.['kimi-code/pythinker-for-coding']?.displayName).toBe('Pythinker for Coding'); - }); - - it('infers default_thinking from fresh managed model capabilities', async () => { - const config: ManagedKimiConfigShape = { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - }, - }, - defaultModel: 'kimi-code/pythinker-for-coding', - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 1000, - capabilities: [], - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); - expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); - }); - - it('preserves explicit default_thinking when preserving a custom default without capabilities', async () => { - const config: ManagedKimiConfigShape = { - providers: { - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - }, - }, - defaultModel: 'custom-default', - defaultThinking: true, - models: { - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 1000, - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('custom-default'); - expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); - }); - - it('defaults default_thinking to false when a preserved custom default has no signal', async () => { - const config: ManagedKimiConfigShape = { - providers: { - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - }, - }, - defaultModel: 'custom-default', - models: { - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 1000, - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('custom-default'); - expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); - }); - - it('does not infer default_thinking from preserved custom default capabilities', async () => { - const config: ManagedKimiConfigShape = { - providers: { - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - }, - }, - defaultModel: 'custom-default', - models: { - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 1000, - capabilities: [], - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('custom-default'); - expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); - }); - - it('keeps default_thinking off even when preserved custom default has thinking capability', async () => { - const config: ManagedKimiConfigShape = { - providers: { - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - }, - }, - defaultModel: 'custom-default', - models: { - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 1000, - capabilities: ['thinking'], - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('custom-default'); - expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); - }); - - it('falls back to the first fetched model when the preserved default was removed', async () => { - const config: ManagedKimiConfigShape = { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - }, - }, - defaultModel: 'kimi-code/stale', - defaultThinking: false, - models: { - 'kimi-code/stale': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'stale', - maxContextSize: 1000, - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); - expect(result.defaultThinking).toBe(false); - expect(config.defaultModel).toBe('kimi-code/pythinker-for-coding'); - expect(config.defaultThinking).toBe(false); - }); - - it('removes managed provider, models, services, and default model on logout', () => { - const config: ManagedKimiConfigShape = { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - }, - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - }, - }, - defaultModel: 'kimi-code/pythinker-for-coding', - defaultThinking: true, - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 262144, - }, - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 1000, - }, - }, - services: { - pythoughtsSearch: { baseUrl: 'https://api.kimi.com/coding/v1/search' }, - pythoughtsFetch: { baseUrl: 'https://api.kimi.com/coding/v1/fetch' }, - customService: { baseUrl: 'https://service.example.test' }, - }, - raw: { - default_model: 'kimi-code/pythinker-for-coding', - providers: { - [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker' }, - custom: { type: 'pythinker' }, - }, - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - }, - 'custom-default': { - provider: 'custom', - model: 'custom-model', - }, - }, - services: { - pythoughts_search: { base_url: 'https://api.kimi.com/coding/v1/search' }, - pythoughts_fetch: { base_url: 'https://api.kimi.com/coding/v1/fetch' }, - }, - }, - }; - - applyManagedKimiCodeLogoutConfig(config); - - expect(config.defaultModel).toBeUndefined(); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); - expect(config.providers['custom']).toBeDefined(); - expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); - expect(config.models?.['custom-default']).toBeDefined(); - expect(config.services?.pythoughtsSearch).toBeUndefined(); - expect(config.services?.pythoughtsFetch).toBeUndefined(); - expect(config.services?.['customService']).toEqual({ - baseUrl: 'https://service.example.test', - }); - }); - - it('rejects managed models that do not include a positive context_length', async () => { - const fetchImpl = vi.fn( - async () => - new Response( - JSON.stringify({ - data: [{ id: 'pythinker-for-coding', supports_reasoning: true }], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ) as unknown as typeof fetch; - - await expect( - fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl, - }), - ).rejects.toThrow(/positive context_length/); - }); - - it('surfaces API error messages from model listing failures', async () => { - const fetchImpl = vi.fn( - async () => - new Response(JSON.stringify({ error: { message: 'quota exceeded' } }), { - status: 429, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch; - - await expect( - fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl, - }), - ).rejects.toThrow('quota exceeded'); - }); - - it('classifies model listing 401 responses as OAuth unauthorized', async () => { - const fetchImpl = vi.fn( - async () => - new Response( - JSON.stringify({ - error: { message: 'The API Key appears to be invalid or may have expired.' }, - }), - { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ) as unknown as typeof fetch; - - await expect( - fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl, - }), - ).rejects.toBeInstanceOf(OAuthUnauthorizedError); - }); - - it('classifies membership-check 402 responses as OAuth unauthorized', async () => { - const fetchImpl = vi.fn( - async () => - new Response( - JSON.stringify({ - error: { - message: - "We're unable to verify your membership benefits at this time. Please ensure your membership is active.", - }, - }), - { - status: 402, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ) as unknown as typeof fetch; - - const promise = fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - baseUrl: 'https://api.dev.example.test/coding/v1', - fetchImpl, - }); - - await expect(promise).rejects.toThrow( - "Kimi Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.", - ); - await expect( - fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - baseUrl: 'https://api.dev.example.test/coding/v1', - fetchImpl, - }), - ).rejects.toMatchObject({ - status: 402, - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - await expect( - fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl, - }), - ).rejects.toBeInstanceOf(OAuthUnauthorizedError); - await expect( - fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl, - }), - ).rejects.toBeInstanceOf(ManagedKimiCodeModelsAuthError); - }); - - it('clears managed provider, models, default model, and services on logout', () => { - const config: ManagedKimiConfigShape = { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - }, - }, - defaultModel: 'kimi-code/pythinker-for-coding', - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 262144, - }, - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 128000, - }, - }, - services: { - pythoughtsSearch: { - baseUrl: 'https://api.kimi.com/coding/v1/search', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - pythoughtsFetch: { - baseUrl: 'https://api.kimi.com/coding/v1/fetch', - apiKey: '', - oauth: { storage: 'file', key: 'oauth/kimi-code' }, - }, - otherService: { baseUrl: 'https://service.example.test' }, - }, - }; - - const result = clearManagedKimiCodeConfig(config); - - expect(result).toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - removedProvider: true, - removedModels: ['kimi-code/pythinker-for-coding'], - defaultModelCleared: true, - removedServices: ['pythoughtsSearch', 'pythoughtsFetch'], - }); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined(); - expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' }); - expect(config.defaultModel).toBeUndefined(); - expect(config.models?.['kimi-code/pythinker-for-coding']).toBeUndefined(); - expect(config.models?.['custom-default']).toMatchObject({ provider: 'custom' }); - expect(config.services?.pythoughtsSearch).toBeUndefined(); - expect(config.services?.pythoughtsFetch).toBeUndefined(); - expect(config.services?.['otherService']).toMatchObject({ - baseUrl: 'https://service.example.test', - }); - }); -}); - -describe('supports_thinking_type', () => { - function makeThinkingTypeModelsResponse(): Response { - return new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - supports_image_in: true, - supports_video_in: true, - supports_thinking_type: 'only', - display_name: 'Pythinker For Coding', - }, - { - // 'no' is the authoritative declaration and overrides the legacy - // supports_reasoning boolean. - id: 'pythinker-plain', - context_length: 128000, - supports_reasoning: true, - supports_thinking_type: 'no', - }, - { - id: 'pythinker-toggle', - context_length: 128000, - supports_reasoning: true, - supports_thinking_type: 'both', - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - - it('parses supports_thinking_type from the models endpoint', async () => { - const models = await fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, - }); - - expect(models[0]?.supportsThinkingType).toBe('only'); - expect(models[1]?.supportsThinkingType).toBe('no'); - expect(models[2]?.supportsThinkingType).toBe('both'); - }); - - it('leaves supportsThinkingType undefined when the field is absent or invalid', async () => { - const absent = await fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch, - }); - expect(absent[0]?.supportsThinkingType).toBeUndefined(); - - const invalid = await fetchManagedKimiCodeModels({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn( - async () => - new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - supports_thinking_type: 'maybe', - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ) as unknown as typeof fetch, - }); - expect(invalid[0]?.supportsThinkingType).toBeUndefined(); - }); - - it('maps the three states onto capabilities, overriding supports_reasoning', async () => { - const config: ManagedKimiConfigShape = { providers: {} }; - - await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - // 'only' → thinking locked on. - expect(config.models?.['kimi-code/pythinker-for-coding']?.capabilities).toEqual([ - 'thinking', - 'always_thinking', - 'image_in', - 'video_in', - 'tool_use', - ]); - // 'no' → no thinking capability despite supports_reasoning=true. - expect(config.models?.['kimi-code/pythinker-plain']?.capabilities).toEqual(['tool_use']); - // 'both' → plain toggleable thinking. - expect(config.models?.['kimi-code/pythinker-toggle']?.capabilities).toEqual([ - 'thinking', - 'tool_use', - ]); - }); - - it('parses supported_reasoning_efforts and writes supportEfforts onto model aliases', async () => { - const config: ManagedKimiConfigShape = { providers: {} }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn( - async () => - new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - supported_reasoning_efforts: ['low', 'medium', 'high', 'max'], - }, - { - id: 'pythinker-plain', - context_length: 262144, - }, - { - id: 'pythinker-invalid', - context_length: 262144, - supported_reasoning_efforts: 'high', - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ) as unknown as typeof fetch, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.models[0]?.supportedReasoningEfforts).toEqual(['low', 'medium', 'high', 'max']); - expect(result.models[1]?.supportedReasoningEfforts).toBeUndefined(); - expect(result.models[2]?.supportedReasoningEfforts).toBeUndefined(); - expect(config.models?.['kimi-code/pythinker-for-coding']?.supportEfforts).toEqual([ - 'low', - 'medium', - 'high', - 'max', - ]); - expect(config.models?.['kimi-code/pythinker-plain']?.supportEfforts).toBeUndefined(); - expect(config.models?.['kimi-code/pythinker-invalid']?.supportEfforts).toBeUndefined(); - }); - - it('forces default thinking on when the selected default model is thinking-only', async () => { - const config: ManagedKimiConfigShape = { providers: {}, defaultThinking: false }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); - expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); - }); - - it('forces default thinking on when preserving a thinking-only managed default', async () => { - const config: ManagedKimiConfigShape = { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - }, - }, - defaultModel: 'kimi-code/pythinker-for-coding', - defaultThinking: false, - models: { - 'kimi-code/pythinker-for-coding': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-for-coding', - maxContextSize: 262144, - capabilities: ['thinking'], - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('kimi-code/pythinker-for-coding'); - expect(result.defaultThinking).toBe(true); - expect(config.defaultThinking).toBe(true); - }); - - it('forces default thinking off when preserving a no-thinking managed default', async () => { - const config: ManagedKimiConfigShape = { - providers: { - [KIMI_CODE_PROVIDER_NAME]: { - type: 'pythinker', - apiKey: '', - }, - }, - defaultModel: 'kimi-code/pythinker-plain', - defaultThinking: true, - models: { - 'kimi-code/pythinker-plain': { - provider: KIMI_CODE_PROVIDER_NAME, - model: 'pythinker-plain', - maxContextSize: 128000, - capabilities: ['thinking'], - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('kimi-code/pythinker-plain'); - expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); - }); - - it('keeps a preserved non-managed default thinking selection untouched', async () => { - const config: ManagedKimiConfigShape = { - providers: { - custom: { - type: 'pythinker', - apiKey: 'sk-existing', - }, - }, - defaultModel: 'custom-default', - defaultThinking: false, - models: { - 'custom-default': { - provider: 'custom', - model: 'custom-model', - maxContextSize: 1000, - }, - }, - }; - - const result = await provisionManagedKimiCodeConfig({ - accessToken: 'oauth-access-token', - fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, - preserveDefaultModel: true, - adapter: { - read: () => config, - write: vi.fn(), - apply: applyManagedKimiCodeConfig, - }, - }); - - expect(result.defaultModel).toBe('custom-default'); - expect(result.defaultThinking).toBe(false); - expect(config.defaultThinking).toBe(false); - }); -}); diff --git a/packages/oauth/test/managed-usage.test.ts b/packages/oauth/test/managed-usage.test.ts deleted file mode 100644 index 09cfc7c9..00000000 --- a/packages/oauth/test/managed-usage.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { afterEach, describe, it, expect, vi } from 'vitest'; - -import { - fetchManagedUsage, - formatDuration, - formatResetTime, - isManagedKimiCode, - parseManagedUsagePayload, -} from '../src/managed-usage'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('isManagedKimiCode', () => { - it('matches only the pythinker-code managed provider', () => { - expect(isManagedKimiCode('managed:kimi-code')).toBe(true); - expect(isManagedKimiCode('managed:pythoughts-labs')).toBe(false); - expect(isManagedKimiCode('openai')).toBe(false); - expect(isManagedKimiCode('')).toBe(false); - expect(isManagedKimiCode(null)).toBe(false); - expect(isManagedKimiCode()).toBe(false); - }); -}); - -describe('parseManagedUsagePayload', () => { - it('returns empty when payload is not an object', () => { - expect(parseManagedUsagePayload(null)).toEqual({ summary: null, limits: [] }); - expect(parseManagedUsagePayload('nope')).toEqual({ summary: null, limits: [] }); - }); - - it('extracts a summary from the `usage` object', () => { - const parsed = parseManagedUsagePayload({ - usage: { used: 40, limit: 1000, name: 'Weekly limit' }, - }); - expect(parsed.summary).toEqual({ - label: 'Weekly limit', - used: 40, - limit: 1000, - }); - expect(parsed.limits).toEqual([]); - }); - - it('falls back to remaining=limit-used when used is absent', () => { - const parsed = parseManagedUsagePayload({ usage: { remaining: 200, limit: 1000 } }); - expect(parsed.summary).toEqual({ label: 'Weekly limit', used: 800, limit: 1000 }); - }); - - it('labels limits from window duration when no name is given', () => { - const parsed = parseManagedUsagePayload({ - limits: [ - { detail: { used: 1, limit: 100 }, window: { duration: 300, timeUnit: 'MINUTE' } }, - { detail: { used: 2, limit: 50 }, window: { duration: 24, timeUnit: 'HOUR' } }, - ], - }); - expect(parsed.limits.map((l) => l.label)).toEqual(['5h limit', '24h limit']); - }); - - it('prefers explicit item.name over window duration label', () => { - const parsed = parseManagedUsagePayload({ - limits: [ - { - name: 'Daily cap', - detail: { used: 5, limit: 100 }, - window: { duration: 1440, timeUnit: 'MINUTE' }, - }, - ], - }); - expect(parsed.limits[0]!.label).toBe('Daily cap'); - }); - - it('surfaces reset hints from resetAt timestamps', () => { - const future = new Date(Date.now() + 3600_000).toISOString(); - const parsed = parseManagedUsagePayload({ usage: { used: 1, limit: 10, resetAt: future } }); - expect(parsed.summary?.resetHint).toMatch(/resets in/); - }); -}); - -describe('fetchManagedUsage', () => { - it('sends only Authorization and Accept headers', async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ usage: { used: 1, limit: 10 } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - vi.stubGlobal('fetch', fetchMock); - - await expect(fetchManagedUsage('https://api.example/usages', 'access-token')).resolves.toEqual({ - kind: 'ok', - parsed: { - summary: { label: 'Weekly limit', used: 1, limit: 10 }, - limits: [], - }, - }); - - const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; - const init = calls[0]?.[1] ?? {}; - const headers = new Headers((init.headers ?? {}) as Record); - expect(headers.get('authorization')).toBe('Bearer access-token'); - expect(headers.get('accept')).toBe('application/json'); - expect(headers.get('user-agent')).toBeNull(); - expect(headers.get('x-msh-platform')).toBeNull(); - }); - - it('surfaces JSON API error messages with status', async () => { - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ message: 'usage quota unavailable' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }), - ), - ); - - const result = await fetchManagedUsage('https://api.example/usages', 'access-token'); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBe(401); - expect(result.message).toBe('usage quota unavailable'); - }); - - it('surfaces nested JSON API error messages', async () => { - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ error: { message: 'usage endpoint moved' } }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }), - ), - ); - - const result = await fetchManagedUsage('https://api.example/usages', 'access-token'); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBe(404); - expect(result.message).toBe('usage endpoint moved'); - }); - - it('falls back to local usage hints when the API error body is empty', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 }))); - - const result = await fetchManagedUsage('https://api.example/usages', 'access-token'); - - expect(result.kind).toBe('error'); - if (result.kind !== 'error') return; - expect(result.status).toBe(404); - expect(result.message).toBe('Usage endpoint not available. Try Kimi For Coding.'); - }); -}); - -describe('formatDuration', () => { - it('formats days/hours/minutes', () => { - expect(formatDuration(0)).toBe('0s'); - expect(formatDuration(45)).toBe('45s'); - expect(formatDuration(90)).toBe('1m'); - expect(formatDuration(3600)).toBe('1h'); - expect(formatDuration(3661)).toBe('1h 1m'); - expect(formatDuration(86_400 + 7200 + 600)).toBe('1d 2h 10m'); - }); -}); - -describe('formatResetTime', () => { - it('returns "reset" for past timestamps', () => { - const past = new Date(Date.now() - 5000).toISOString(); - expect(formatResetTime(past)).toBe('reset'); - }); - - it('returns "resets in X" for future timestamps', () => { - const future = new Date(Date.now() + 3600_000).toISOString(); - expect(formatResetTime(future)).toMatch(/^resets in /); - }); - - it('falls back when parsing fails', () => { - expect(formatResetTime('not-a-date')).toBe('resets at not-a-date'); - }); -}); diff --git a/packages/oauth/test/oauth-manager-lock-failure.test.ts b/packages/oauth/test/oauth-manager-lock-failure.test.ts deleted file mode 100644 index 508ed055..00000000 --- a/packages/oauth/test/oauth-manager-lock-failure.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { mkdirSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { OAuthError } from '../src/errors'; -import { OAuthManager } from '../src/oauth-manager'; -import type { TokenStorage } from '../src/storage'; -import type { OAuthFlowConfig, TokenInfo } from '../src/types'; - -const lockMock = vi.hoisted(() => ({ - lock: vi.fn(), -})); - -vi.mock('proper-lockfile', () => ({ - default: { - lock: lockMock.lock, - }, -})); - -class InMemoryStorage implements TokenStorage { - public token: TokenInfo | undefined; - - async load(): Promise { - return this.token; - } - - async save(_name: string, token: TokenInfo): Promise { - this.token = token; - } - - async remove(): Promise { - this.token = undefined; - } - - async list(): Promise { - return this.token === undefined ? [] : ['pythinker-code']; - } -} - -const config: OAuthFlowConfig = { - name: 'pythinker-code', - oauthHost: 'https://unused.test', - clientId: 'test-client-id', -}; - -function makeToken(overrides: Partial = {}): TokenInfo { - return { - accessToken: 'at-old', - refreshToken: 'rt-old', - expiresAt: 1_000_000_100, - scope: '', - tokenType: 'Bearer', - expiresIn: 3600, - ...overrides, - }; -} - -describe('OAuthManager refresh lock failure', () => { - let dir: string; - - beforeEach(() => { - dir = join( - tmpdir(), - `pythinker-oauth-lock-failure-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(dir, { recursive: true }); - lockMock.lock.mockReset(); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it('fails closed instead of refreshing without a configured cross-process lock', async () => { - const storage = new InMemoryStorage(); - storage.token = makeToken(); - lockMock.lock.mockRejectedValue(new Error('lock busy')); - const refreshImpl = vi.fn().mockResolvedValue(makeToken({ accessToken: 'at-new' })); - - const mgr = new OAuthManager({ - config, - storage, - configDir: dir, - now: () => 1_000_000_000, - refreshTokenImpl: refreshImpl, - }); - - await expect(mgr.ensureFresh()).rejects.toBeInstanceOf(OAuthError); - await expect(mgr.ensureFresh()).rejects.toThrow(/refresh lock/i); - expect(refreshImpl).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/oauth/test/oauth-manager-multi-process.test.ts b/packages/oauth/test/oauth-manager-multi-process.test.ts deleted file mode 100644 index 2b5f944f..00000000 --- a/packages/oauth/test/oauth-manager-multi-process.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * OAuthManager cross-process refresh lock. - * - * Spawns N Node worker processes that each call `ensureFresh(force=true)` - * on the same OAuth provider. With the `proper-lockfile`-backed - * cross-process mutex around `doEnsureFresh`, only one worker actually - * hits the refresh endpoint (`refreshImpl`); the others re-read storage - * and see the rotated token produced by the winner. - * - * Workers run as inline `.mjs` scripts via `spawnInlineWorkers`. Each - * worker: - * 1. Dynamically imports `OAuthManager` from the current package source. - * 2. Constructs it with a file-backed TokenStorage pointing at - * `{shareDir}/token.json` and a `refreshTokenImpl` that increments - * `{shareDir}/refresh-count.txt` atomically before returning a - * rotated token (refreshToken changes every refresh). - * 3. Calls `ensureFresh({force:true})` and exits. - * - * Oracle: after all workers exit, `refresh-count.txt` contains exactly - * `1` (when the lock is in place); `N` (when it is not). - * - * **Platform**: macOS / Linux only. Windows path quirks for - * `proper-lockfile` are bypassed via the `PYTHINKER_DISABLE_OAUTH_LOCK=1` - * env-var escape hatch; this test skips on `process.platform === 'win32'`. - */ - -import { mkdir, stat } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { afterEach, describe, expect, it } from 'vitest'; - -import { createTempWorkDir, spawnInlineWorkers, type TempDirHandle } from './helpers'; - -const skipOnWindows = process.platform === 'win32'; -const OAUTH_ENTRY_URL = new URL('../src/index.ts', import.meta.url).href; - -// ───────────────────────────────────────────────────────────────────── -// Worker body — dedicated inline .mjs script. -// ───────────────────────────────────────────────────────────────────── -// -// One worker = one ensureFresh(force=true) invocation. All workers race -// against the same on-disk lock file and the same refresh-count.txt. -// -// `refresh-count.txt` starts empty (or missing). Workers atomically -// append a single byte per observed refresh using O_APPEND semantics; -// the final byte count equals the number of refreshes that took place. - -const WORKER_SCRIPT = ` - import { readFile, writeFile, appendFile, mkdir, stat } from 'node:fs/promises'; - import { join } from 'node:path'; - const { OAuthManager } = await import(process.env.PYTHINKER_OAUTH_ENTRY); - - const shareDir = process.env.PYTHINKER_CODE_HOME; - const tokenPath = join(shareDir, 'token.json'); - const counterPath = join(shareDir, 'refresh-count.txt'); - const readyPath = join(shareDir, 'first-load-ready.txt'); - const lockDir = join(shareDir, 'oauth'); - await mkdir(lockDir, { recursive: true }); - - async function waitForFirstLoadBarrier() { - if (process.env.PYTHINKER_SYNC_FIRST_LOAD !== '1') return; - await appendFile(readyPath, '.'); - const expected = Number(process.env.PYTHINKER_WORKER_COUNT || '1'); - const deadline = Date.now() + 10_000; - while (true) { - let ready = 0; - try { - ready = (await stat(readyPath)).size; - } catch {} - if (ready >= expected) return; - if (Date.now() >= deadline) { - throw new Error('first-load barrier timed out'); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - - const config = { - name: 'test-provider', - oauthHost: 'https://unused.test', - clientId: 'test', - }; - - /** File-backed TokenStorage keyed on the single provider 'test-provider'. */ - let firstLoad = true; - const storage = { - async load(name) { - try { - const raw = await readFile(tokenPath, 'utf8'); - if (firstLoad) { - firstLoad = false; - await waitForFirstLoadBarrier(); - } - const parsed = JSON.parse(raw); - return parsed[name]; - } catch { - return undefined; - } - }, - async save(name, token) { - // Read-modify-write. Good enough for the test oracle; the real - // cross-process correctness comes from the lock, not the storage. - let bag = {}; - try { - bag = JSON.parse(await readFile(tokenPath, 'utf8')); - } catch { - bag = {}; - } - bag[name] = token; - await writeFile(tokenPath, JSON.stringify(bag), 'utf8'); - }, - async remove(name) {}, - async list() { return ['test-provider']; }, - }; - - /** refreshImpl increments the oracle file and hands back a rotated token. */ - const refreshImpl = async () => { - // One byte per observed refresh; O_APPEND is atomic on POSIX. - await appendFile(counterPath, '.'); - const nowSec = Math.floor(Date.now() / 1000); - return { - accessToken: 'at-refreshed-' + String(nowSec), - refreshToken: 'rt-rotated-' + String(nowSec), - expiresAt: nowSec + 3600, - scope: '', - tokenType: 'Bearer', - expiresIn: 3600, - }; - }; - - const manager = new OAuthManager({ - config, - storage, - refreshTokenImpl: refreshImpl, - // minimal stubs — unused on ensureFresh - requestDeviceImpl: async () => { throw new Error('unused'); }, - pollDeviceImpl: async () => { throw new Error('unused'); }, - now: () => Math.floor(Date.now() / 1000), - }); - - // Every worker attempts a forced refresh. With a cross-process lock - // in place, only one worker's refreshImpl runs; the others read the - // rotated storage and return its accessToken without calling - // refreshImpl. - try { - const token = await manager.ensureFresh({ force: true }); - process.stdout.write('ok:' + token + '\\n'); - } catch (err) { - process.stdout.write('err:' + (err && err.message ? err.message : String(err)) + '\\n'); - } - // Debug trace so the test oracle can diagnose mis-locking. - if (process.env.DEBUG_OAUTH_WORKER === '1') { - process.stderr.write('[worker ' + process.env.PYTHINKER_WORKER_ID + '] done\\n'); - } -`; - -async function seedInitialToken(shareDir: string): Promise { - const tokenPath = join(shareDir, 'token.json'); - const nowSec = Math.floor(Date.now() / 1000); - const token = { - 'test-provider': { - accessToken: 'at-initial', - refreshToken: 'rt-initial', - expiresAt: nowSec + 60, // inside refresh threshold → force refresh hits - scope: '', - tokenType: 'Bearer', - expiresIn: 3600, - }, - }; - const { writeFile } = await import('node:fs/promises'); - await writeFile(tokenPath, JSON.stringify(token), 'utf8'); -} - -async function readRefreshCount(shareDir: string): Promise { - const counterPath = join(shareDir, 'refresh-count.txt'); - try { - const s = await stat(counterPath); - return s.size; - } catch { - return 0; - } -} - -const tmpHandles: TempDirHandle[] = []; - -afterEach(async () => { - while (tmpHandles.length > 0) { - await tmpHandles.pop()!.cleanup(); - } -}); - -describe.skipIf(skipOnWindows)('OAuthManager cross-process refresh lock', () => { - it('2 workers concurrently force-refresh → exactly one refreshImpl fires', async () => { - const dir = await createTempWorkDir(); - tmpHandles.push(dir); - await seedInitialToken(dir.path); - - const workers = await spawnInlineWorkers({ - count: 2, - inlineScript: WORKER_SCRIPT, - tmpDir: dir.path, - shareDir: dir.path, - timeoutMs: 30_000, - env: { - PYTHINKER_OAUTH_ENTRY: OAUTH_ENTRY_URL, - PYTHINKER_SYNC_FIRST_LOAD: '1', - PYTHINKER_WORKER_COUNT: '2', - }, - }); - - // All workers exit cleanly. - for (const w of workers) { - expect(w.exitCode, `worker ${String(w.id)} stderr: ${w.stderr}`).toBe(0); - expect(w.stdout.startsWith('ok:')).toBe(true); - } - - // Refresh count = 1 → exactly one refresh happened across the 5 - // processes. Without the lock the count equals N (or any value > 1). - const count = await readRefreshCount(dir.path); - expect(count).toBe(1); - }, 45_000); - - it('stale lock (held by a killed worker) is reclaimed after stale timeout', async () => { - // Scenario: worker A takes the lock and crashes without releasing - // (SIGKILL). Worker B arrives 6+ seconds later and must reclaim - // the stale lock via `proper-lockfile`'s `stale: 5_000ms` policy. - // - // BLK-2 fix: proper-lockfile represents the lock as a DIRECTORY - // at `{target}.lock/`. The staleness probe is `stat().mtimeMs` - // on that directory, so we must `mkdir` + `utimes` (not - // `writeFile`, which would put a regular file where a dir is - // expected — `proper-lockfile` would then blow up or - // mis-interpret it). - const dir = await createTempWorkDir(); - tmpHandles.push(dir); - await seedInitialToken(dir.path); - await mkdir(join(dir.path, 'oauth'), { recursive: true }); - - const { utimes } = await import('node:fs/promises'); - const lockDir = join(dir.path, 'oauth', 'test-provider.lock'); - await mkdir(lockDir, { recursive: true }); - // 10 seconds ago — past the 5 s stale threshold. - const tenSecondsAgo = (Date.now() - 10_000) / 1000; - await utimes(lockDir, tenSecondsAgo, tenSecondsAgo); - - const workers = await spawnInlineWorkers({ - count: 1, - inlineScript: WORKER_SCRIPT, - tmpDir: dir.path, - shareDir: dir.path, - timeoutMs: 20_000, - env: { - PYTHINKER_OAUTH_ENTRY: OAUTH_ENTRY_URL, - }, - }); - - expect(workers[0]?.exitCode).toBe(0); - expect(workers[0]?.stdout.startsWith('ok:')).toBe(true); - }, 30_000); -}); - -// Prevent "no tests in file" when running on Windows. -describe.skipIf(!skipOnWindows)('OAuthManager cross-process refresh lock (Windows skip)', () => { - it('skipped on Windows — covered by PYTHINKER_DISABLE_OAUTH_LOCK=1 env escape hatch', () => { - expect(skipOnWindows).toBe(true); - }); -}); - diff --git a/packages/oauth/test/oauth-manager.test.ts b/packages/oauth/test/oauth-manager.test.ts deleted file mode 100644 index 1464d2af..00000000 --- a/packages/oauth/test/oauth-manager.test.ts +++ /dev/null @@ -1,903 +0,0 @@ -/** - * OAuthManager tests — exercise ensureFresh / login / logout against a fake - * storage and injected transport mocks. No network, no file locks. - * - * We inject `refreshTokenImpl`, `pollDeviceImpl`, `requestDeviceImpl`, `now`, - * and `sleep` for determinism. The storage is an in-memory implementation. - */ - -import { mkdirSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { DeviceCodeTimeoutError, OAuthUnauthorizedError } from '../src/errors'; -import type { DevicePollResult } from '../src/oauth'; -import { OAuthManager } from '../src/oauth-manager'; -import { FileTokenStorage } from '../src/storage'; -import type { TokenStorage } from '../src/storage'; -import type { DeviceAuthorization, OAuthFlowConfig, TokenInfo } from '../src/types'; - -class InMemoryStorage implements TokenStorage { - public store = new Map(); - - async load(name: string): Promise { - return this.store.get(name); - } - - async save(name: string, token: TokenInfo): Promise { - this.store.set(name, token); - } - - async remove(name: string): Promise { - this.store.delete(name); - } - - async list(): Promise { - return [...this.store.keys()]; - } -} - -const config: OAuthFlowConfig = { - name: 'pythinker-code', - oauthHost: 'https://test', - clientId: 'test', -}; - -function makeToken(overrides: Partial = {}): TokenInfo { - return { - accessToken: 'at-1', - refreshToken: 'rt-1', - expiresAt: 2_000_000_000, // far future - scope: '', - tokenType: 'Bearer', - expiresIn: 3600, - ...overrides, - }; -} - -let currentNow = 1_000_000_000; -function now(): number { - return currentNow; -} - -beforeEach(() => { - currentNow = 1_000_000_000; -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -// ── ensureFresh ─────────────────────────────────────────────────────── - -describe('OAuthManager.ensureFresh', () => { - it('returns stored access_token when not close to expiry', async () => { - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 7200 })); - const refreshImpl = vi.fn(); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - }); - const access = await mgr.ensureFresh(); - expect(access).toBe('at-1'); - expect(refreshImpl).not.toHaveBeenCalled(); - }); - - it('refreshes when within dynamic threshold', async () => { - const storage = new InMemoryStorage(); - // expires in 200s, threshold = max(300, 3600*0.5) = 1800. 200 < 1800 → refresh - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 200 })); - const refreshed = makeToken({ - accessToken: 'at-new', - refreshToken: 'rt-new', - expiresAt: currentNow + 3600, - }); - const refreshImpl = vi.fn().mockResolvedValue(refreshed); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - const access = await mgr.ensureFresh(); - expect(refreshImpl).toHaveBeenCalledWith(config, 'rt-1'); - expect(access).toBe('at-new'); - expect((await storage.load('pythinker-code'))?.accessToken).toBe('at-new'); - }); - - it('force=true always refreshes', async () => { - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 7200 })); - const refreshImpl = vi.fn().mockResolvedValue(makeToken({ accessToken: 'forced' })); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - const access = await mgr.ensureFresh({ force: true }); - expect(refreshImpl).toHaveBeenCalled(); - expect(access).toBe('forced'); - }); - - it('force=true refreshes an unchanged freshly-issued token', async () => { - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-fresh', - refreshToken: 'rt-fresh', - expiresAt: currentNow + 3600, - expiresIn: 3600, - }), - ); - const refreshImpl = vi.fn().mockResolvedValue( - makeToken({ - accessToken: 'forced-fresh', - refreshToken: 'rt-forced', - expiresAt: currentNow + 7200, - }), - ); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - const access = await mgr.ensureFresh({ force: true }); - expect(refreshImpl).toHaveBeenCalledTimes(1); - expect(access).toBe('forced-fresh'); - }); - - it('force=true reuses a token changed by another process while waiting for the lock', async () => { - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-old', - refreshToken: 'rt-old', - expiresAt: currentNow + 100, - }), - ); - const refreshImpl = vi.fn(); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - - const originalLoad = storage.load.bind(storage); - let callCount = 0; - storage.load = async (name: string) => { - callCount += 1; - if (callCount === 2) { - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-peer', - refreshToken: 'rt-peer', - expiresAt: currentNow + 3600, - }), - ); - } - return originalLoad(name); - }; - - const access = await mgr.ensureFresh({ force: true }); - expect(access).toBe('at-peer'); - expect(refreshImpl).not.toHaveBeenCalled(); - }); - - it('concurrent ensureFresh calls share a single refresh', async () => { - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 200 })); - let refreshCount = 0; - const refreshImpl = vi.fn().mockImplementation(async () => { - refreshCount += 1; - return makeToken({ accessToken: `at-${refreshCount}` }); - }); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - const [a, b, c] = await Promise.all([mgr.ensureFresh(), mgr.ensureFresh(), mgr.ensureFresh()]); - expect(refreshCount).toBe(1); - expect(a).toBe(b); - expect(b).toBe(c); - }); - - it('does not let a force=true caller piggyback a non-force in-flight refresh', async () => { - // The non-force caller arrives first while the stored token is still - // fresh enough to short-circuit (no refresh would fire). A later - // force=true caller must NOT receive that cached short-circuit — - // forced rotation has its own semantics (e.g. recovery after a 401 - // upstream) that the non-force coalesce path cannot satisfy. - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 7200 })); - let refreshCount = 0; - const refreshImpl = vi.fn().mockImplementation(async () => { - refreshCount += 1; - return makeToken({ accessToken: `forced-${refreshCount}`, refreshToken: 'rt-new' }); - }); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - - const nonForce = mgr.ensureFresh(); - const forced = mgr.ensureFresh({ force: true }); - const [nonForceResult, forcedResult] = await Promise.all([nonForce, forced]); - - expect(refreshCount).toBe(1); - // Non-force saw the still-fresh cached token; force=true got the - // refresh it actually asked for. - expect(nonForceResult).toBe('at-1'); - expect(forcedResult).toBe('forced-1'); - }); - - it('lets a non-force caller piggyback a force=true in-flight refresh', async () => { - // The reverse direction is always safe: a non-force caller is happy - // with anything the in-flight call returns, so we MUST coalesce - // rather than spawn a second refresh round-trip. - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 7200 })); - let refreshCount = 0; - const refreshImpl = vi.fn().mockImplementation(async () => { - refreshCount += 1; - return makeToken({ accessToken: `forced-${refreshCount}` }); - }); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - - const forced = mgr.ensureFresh({ force: true }); - const nonForce = mgr.ensureFresh(); - const [forcedResult, nonForceResult] = await Promise.all([forced, nonForce]); - - expect(refreshCount).toBe(1); - expect(forcedResult).toBe('forced-1'); - expect(nonForceResult).toBe(forcedResult); - }); - - it('coalesces concurrent force=true callers onto a single refresh', async () => { - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 7200 })); - let refreshCount = 0; - const refreshImpl = vi.fn().mockImplementation(async () => { - refreshCount += 1; - return makeToken({ accessToken: `forced-${refreshCount}` }); - }); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - - const [a, b, c] = await Promise.all([ - mgr.ensureFresh({ force: true }), - mgr.ensureFresh({ force: true }), - mgr.ensureFresh({ force: true }), - ]); - - expect(refreshCount).toBe(1); - expect(a).toBe(b); - expect(b).toBe(c); - }); - - it('coalesces multiple queued force callers behind a single non-force in-flight refresh', async () => { - // While a non-force call is in flight, several force=true callers - // may arrive. They cannot piggyback the non-force result, but they - // SHOULD share a single forced refresh among themselves once the - // non-force call settles — otherwise N concurrent 401 recoveries - // would each burn a separate OAuth round-trip. - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 7200 })); - let refreshCount = 0; - const refreshImpl = vi.fn().mockImplementation(async () => { - refreshCount += 1; - return makeToken({ accessToken: `forced-${refreshCount}` }); - }); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - - const nonForce = mgr.ensureFresh(); - const force1 = mgr.ensureFresh({ force: true }); - const force2 = mgr.ensureFresh({ force: true }); - const [nonForceResult, force1Result, force2Result] = await Promise.all([ - nonForce, - force1, - force2, - ]); - - expect(refreshCount).toBe(1); - expect(nonForceResult).toBe('at-1'); - expect(force1Result).toBe('forced-1'); - expect(force2Result).toBe(force1Result); - }); - - it('starts a fresh forced refresh after the non-force in-flight call fails', async () => { - // Edge case: the non-force in-flight call rejects (e.g. transient - // network error). A queued force caller must still get its forced - // refresh — the failure of the unrelated non-force call must not - // bleed into the force caller's outcome. - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 200 })); - const refreshImpl = vi - .fn() - .mockRejectedValueOnce(new Error('network unreachable')) - .mockResolvedValueOnce(makeToken({ accessToken: 'forced-recovery' })); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - sleep: async () => {}, - }); - - const nonForcePromise = mgr.ensureFresh().catch((error: unknown) => error); - const forcedPromise = mgr.ensureFresh({ force: true }); - const [nonForceOutcome, forcedResult] = await Promise.all([nonForcePromise, forcedPromise]); - - expect((nonForceOutcome as Error).message).toMatch(/network unreachable/); - expect(forcedResult).toBe('forced-recovery'); - expect(refreshImpl).toHaveBeenCalledTimes(2); - }); - - it('throws when no stored token (caller should drive /login)', async () => { - const storage = new InMemoryStorage(); - const mgr = new OAuthManager({ config, storage, now }); - await expect(mgr.ensureFresh()).rejects.toBeInstanceOf(OAuthUnauthorizedError); - await expect(mgr.ensureFresh()).rejects.toThrow(/no token/i); - }); - - it('tombstones the stored token on OAuthUnauthorizedError (refresh_token rejected)', async () => { - // Keep the file (so a peer can observe "previously logged in, now - // rejected") but blank out access_token and refresh_token so neither - // this process nor a fresh one will try to reuse the rejected token. - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-retained', - refreshToken: 'rt-retained', - expiresAt: currentNow + 100, - }), - ); - const refreshImpl = vi.fn().mockRejectedValue(new OAuthUnauthorizedError('invalid_grant')); - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - await expect(mgr.ensureFresh()).rejects.toBeInstanceOf(OAuthUnauthorizedError); - const retained = await storage.load('pythinker-code'); - expect(retained).toBeDefined(); - expect(retained?.accessToken).toBe(''); - expect(retained?.refreshToken).toBe(''); - }); - - it('does NOT delete file if 401 happens after another process rotated (M5)', async () => { - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-old', - refreshToken: 'rt-old', - expiresAt: currentNow + 100, - }), - ); - // Simulate: our refresh attempt fails 401 because rt-old was rotated by - // another process; the new token is already in storage. - let refreshAttempts = 0; - const refreshImpl = vi.fn().mockImplementation(async (_cfg, rt: string) => { - refreshAttempts += 1; - if (rt === 'rt-old') { - // Race: while we were calling refresh, another process rotated. - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-rotated', - refreshToken: 'rt-rotated', - expiresAt: currentNow + 7200, - }), - ); - throw new OAuthUnauthorizedError('rt-old already rotated'); - } - return makeToken({ accessToken: 'should-not-reach' }); - }); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - sleep: async () => {}, - }); - // Should NOT throw — should re-read the rotated token and return it - const access = await mgr.ensureFresh(); - expect(access).toBe('at-rotated'); - // File should still have the rotated token - expect((await storage.load('pythinker-code'))?.accessToken).toBe('at-rotated'); - expect(refreshAttempts).toBe(1); - }); - - // ── force=true propagates errors (no silent swallow) ────────────────── - - it('force=true surfaces OAuthUnauthorizedError to the caller', async () => { - // `force=true` must not paper over a genuinely revoked refresh_token. - // Caller drives /login to recover; ensureFresh throws so the error - // is observable. - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ expiresAt: currentNow + 7200, refreshToken: 'rt-revoked' }), - ); - const refreshImpl = vi - .fn() - .mockRejectedValue(new OAuthUnauthorizedError('refresh_token revoked')); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - sleep: () => Promise.resolve(), - }); - - await expect(mgr.ensureFresh({ force: true })).rejects.toBeInstanceOf(OAuthUnauthorizedError); - // Tombstone on disk so a fresh process won't retry the dead refresh_token. - const retained = await storage.load('pythinker-code'); - expect(retained).toBeDefined(); - expect(retained?.accessToken).toBe(''); - expect(retained?.refreshToken).toBe(''); - }); - - it('force=true surfaces network errors without swallowing', async () => { - // A transport error inside force=true must reach the caller — - // the caller owns the try/catch policy, not ensureFresh. - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken({ expiresAt: currentNow + 7200 })); - const refreshImpl = vi.fn().mockRejectedValue(new Error('ECONNRESET: network unreachable')); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - sleep: () => Promise.resolve(), - }); - - await expect(mgr.ensureFresh({ force: true })).rejects.toThrow(/ECONNRESET/); - // Network error is NOT a revocation signal — storage must stay intact. - expect(await storage.load('pythinker-code')).toBeDefined(); - }); - - it('uses fresh stored token when another process already rotated', async () => { - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-old', - refreshToken: 'rt-old', - expiresAt: currentNow + 100, - }), - ); - const refreshImpl = vi.fn(); // should NOT be called — latest is fresh - const mgr = new OAuthManager({ config, storage, now, refreshTokenImpl: refreshImpl }); - - // Second load call returns an externally-rotated token that's fresh. - const originalLoad = storage.load.bind(storage); - let callCount = 0; - storage.load = async (name: string) => { - callCount += 1; - if (callCount === 2) { - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-rotated', - refreshToken: 'rt-rotated', - expiresAt: currentNow + 3600, - }), - ); - } - return originalLoad(name); - }; - - const access = await mgr.ensureFresh(); - expect(access).toBe('at-rotated'); - expect(refreshImpl).not.toHaveBeenCalled(); - }); -}); - -describe('OAuthManager.ensureFresh — rejected refresh token retention', () => { - it('suppresses a rejected refresh_token until the on-disk token rotates', async () => { - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-stale', - refreshToken: 'rt-rejected-until-rotate', - expiresAt: currentNow + 100, - }), - ); - const refreshImpl = vi.fn().mockRejectedValue(new OAuthUnauthorizedError('invalid_grant')); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - sleep: async () => {}, - }); - - await expect(mgr.ensureFresh({ force: true })).rejects.toBeInstanceOf(OAuthUnauthorizedError); - // Tombstoned on disk — fresh processes load this and see "rejected". - const persistedAfter401 = await storage.load('pythinker-code'); - expect(persistedAfter401).toBeDefined(); - expect(persistedAfter401?.accessToken).toBe(''); - expect(persistedAfter401?.refreshToken).toBe(''); - expect(await mgr.hasToken()).toBe(false); - expect(await mgr.getCachedAccessToken()).toBeUndefined(); - - currentNow += 10_000; - await expect(mgr.ensureFresh()).rejects.toBeInstanceOf(OAuthUnauthorizedError); - // The tombstone short-circuits before refreshImpl can be called again. - expect(refreshImpl).toHaveBeenCalledTimes(1); - - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-rotated', - refreshToken: 'rt-rotated', - expiresAt: currentNow + 7200, - }), - ); - - await expect(mgr.hasToken()).resolves.toBe(true); - await expect(mgr.getCachedAccessToken()).resolves.toBe('at-rotated'); - await expect(mgr.ensureFresh()).resolves.toBe('at-rotated'); - expect(refreshImpl).toHaveBeenCalledTimes(1); - }); - - it('returns the stored access_token when expires_at is 0 (unknown expiry)', async () => { - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-zero-expiry', - refreshToken: 'rt-zero-expiry', - expiresAt: 0, - }), - ); - const refreshImpl = vi - .fn() - .mockResolvedValue(makeToken({ accessToken: 'at-should-not-refresh' })); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - sleep: async () => {}, - }); - - await expect(mgr.ensureFresh()).resolves.toBe('at-zero-expiry'); - expect(refreshImpl).not.toHaveBeenCalled(); - }); - - it('tombstones the on-disk token after 401 so a fresh process sees logged-out', async () => { - // After a refresh_token rejection we keep the file (so concurrent peers - // can observe the state and so we don't lose diagnostic info), but the - // persisted token MUST itself indicate "not usable" — otherwise a fresh - // process with an empty in-memory suppression cache would happily try - // to refresh the dead token again and burn an OAuth server round-trip - // every time. Tombstone = empty access_token + empty refresh_token. - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ - accessToken: 'at-stale', - refreshToken: 'rt-rejected', - expiresAt: currentNow + 100, - }), - ); - const refreshImpl = vi.fn().mockRejectedValue(new OAuthUnauthorizedError('invalid_grant')); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - sleep: async () => {}, - }); - - await expect(mgr.ensureFresh({ force: true })).rejects.toBeInstanceOf(OAuthUnauthorizedError); - - const persistedAfter401 = await storage.load('pythinker-code'); - expect(persistedAfter401).toBeDefined(); - expect(persistedAfter401?.accessToken).toBe(''); - expect(persistedAfter401?.refreshToken).toBe(''); - }); -}); - -// ── login ───────────────────────────────────────────────────────────── - -describe('OAuthManager.login', () => { - function okAuth(): DeviceAuthorization { - return { - userCode: 'WDJB-MJHT', - deviceCode: 'dev123', - verificationUri: 'https://auth/verify', - verificationUriComplete: 'https://auth/verify?user_code=WDJB-MJHT', - expiresIn: 600, - interval: 5, - }; - } - - it('drives device flow to success and persists token', async () => { - const storage = new InMemoryStorage(); - const requestImpl = vi.fn().mockResolvedValue(okAuth()); - const pollResponses: DevicePollResult[] = [ - { kind: 'pending', errorCode: 'authorization_pending', description: '' }, - { kind: 'pending', errorCode: 'authorization_pending', description: '' }, - { kind: 'success', token: makeToken({ accessToken: 'at-login' }) }, - ]; - const pollImpl = vi.fn().mockImplementation(async () => pollResponses.shift()!); - - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: requestImpl, - pollDeviceImpl: pollImpl, - sleep: async () => {}, - }); - - const onDeviceCode = vi.fn(); - const result = await mgr.login({ onDeviceCode }); - expect(result.accessToken).toBe('at-login'); - expect(await storage.load('pythinker-code')).toBeDefined(); - expect(onDeviceCode).toHaveBeenCalledTimes(1); - }); - - it('awaits async onDeviceCode before polling', async () => { - const storage = new InMemoryStorage(); - let deviceCodeDelivered = false; - const pollImpl = vi.fn().mockImplementation(async (): Promise => { - expect(deviceCodeDelivered).toBe(true); - return { kind: 'success', token: makeToken({ accessToken: 'at-login' }) }; - }); - - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: vi.fn().mockResolvedValue(okAuth()), - pollDeviceImpl: pollImpl, - sleep: async () => {}, - }); - - await mgr.login({ - onDeviceCode: async () => { - await Promise.resolve(); - deviceCodeDelivered = true; - }, - }); - - expect(pollImpl).toHaveBeenCalledTimes(1); - }); - - it('throws DeviceCodeTimeoutError when local 15-min budget exceeds', async () => { - const storage = new InMemoryStorage(); - const requestImpl = vi.fn().mockResolvedValue(okAuth()); - const pollImpl = vi.fn().mockResolvedValue({ - kind: 'pending' as const, - errorCode: 'authorization_pending', - description: '', - }); - // sleep mock also advances `currentNow` to simulate wall clock - const sleep = vi.fn().mockImplementation(async (ms: number) => { - currentNow += Math.ceil(ms / 1000); - }); - - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: requestImpl, - pollDeviceImpl: pollImpl, - sleep, - deviceCodeTimeoutMs: 10_000, // 10s for test - }); - - await expect(mgr.login()).rejects.toBeInstanceOf(DeviceCodeTimeoutError); - expect(await storage.load('pythinker-code')).toBeUndefined(); - }); - - it('throws on denied', async () => { - const storage = new InMemoryStorage(); - const pollImpl = vi.fn().mockResolvedValue({ - kind: 'denied' as const, - description: 'user rejected', - }); - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: vi.fn().mockResolvedValue(okAuth()), - pollDeviceImpl: pollImpl, - sleep: async () => {}, - }); - await expect(mgr.login()).rejects.toThrow(/denied|reject/i); - }); - - it('restarts device flow when server reports expired_token', async () => { - const storage = new InMemoryStorage(); - const requestImpl = vi.fn().mockResolvedValue(okAuth()); - const pollResponses: DevicePollResult[] = [ - { kind: 'expired' }, - { kind: 'success', token: makeToken() }, - ]; - const pollImpl = vi.fn().mockImplementation(async () => pollResponses.shift()!); - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: requestImpl, - pollDeviceImpl: pollImpl, - sleep: async () => {}, - }); - const token = await mgr.login(); - expect(token.accessToken).toBe('at-1'); - expect(requestImpl).toHaveBeenCalledTimes(2); - }); - - it('respects AbortSignal during polling', async () => { - const storage = new InMemoryStorage(); - const pollImpl = vi.fn().mockResolvedValue({ - kind: 'pending' as const, - errorCode: 'authorization_pending', - description: '', - }); - const ac = new AbortController(); - const sleep = vi.fn().mockImplementation(async () => { - ac.abort(); - }); - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: vi.fn().mockResolvedValue({ - userCode: 'U', - deviceCode: 'D', - verificationUri: '', - verificationUriComplete: 'https://x', - expiresIn: 600, - interval: 1, - }), - pollDeviceImpl: pollImpl, - sleep, - }); - await expect(mgr.login({ signal: ac.signal })).rejects.toThrow(/abort/i); - }); -}); - -// ── logout & hasToken ───────────────────────────────────────────────── - -describe('OAuthManager.logout and hasToken', () => { - it('logout removes stored token', async () => { - const storage = new InMemoryStorage(); - await storage.save('pythinker-code', makeToken()); - const mgr = new OAuthManager({ config, storage, now }); - await mgr.logout(); - expect(await storage.load('pythinker-code')).toBeUndefined(); - }); - - it('hasToken returns true when stored, false otherwise', async () => { - const storage = new InMemoryStorage(); - const mgr = new OAuthManager({ config, storage, now }); - expect(await mgr.hasToken()).toBe(false); - await storage.save('pythinker-code', makeToken()); - expect(await mgr.hasToken()).toBe(true); - }); - - it('treats an empty stored access_token as missing', async () => { - const storage = new InMemoryStorage(); - await storage.save( - 'pythinker-code', - makeToken({ accessToken: '', refreshToken: 'rt-empty-access-token' }), - ); - const mgr = new OAuthManager({ config, storage, now }); - expect(await mgr.getCachedAccessToken()).toBeUndefined(); - expect(await mgr.hasToken()).toBe(false); - }); -}); - -// ── slow_down RFC 8628 §3.5 ──────────────────────────────────────────── - -describe('OAuthManager.login — slow_down handling', () => { - it('increases polling interval by 5s on slow_down (RFC 8628 §3.5)', async () => { - const storage = new InMemoryStorage(); - const sleepCalls: number[] = []; - const sleep = async (ms: number): Promise => { - sleepCalls.push(ms); - }; - let n = 0; - const pollImpl = async (): Promise => { - n += 1; - if (n === 1) return { kind: 'pending', errorCode: 'authorization_pending', description: '' }; - if (n === 2) return { kind: 'pending', errorCode: 'slow_down', description: '' }; - if (n === 3) return { kind: 'pending', errorCode: 'slow_down', description: '' }; - return { kind: 'success', token: makeToken() }; - }; - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: async () => ({ - userCode: 'U', - deviceCode: 'D', - verificationUri: '', - verificationUriComplete: 'https://x', - expiresIn: 600, - interval: 5, // baseline - }), - pollDeviceImpl: pollImpl, - sleep, - }); - await mgr.login(); - // After 1st pending → sleep 5s. After slow_down #2 → +5 = 10s. - // After slow_down #3 → +5 = 15s. Then success (no sleep). - expect(sleepCalls).toEqual([5000, 10_000, 15_000]); - }); -}); - -// ── FileTokenStorage integration ─────────────────────────────────────── - -describe('OAuthManager + FileTokenStorage integration', () => { - let dir: string; - - beforeEach(() => { - dir = join(tmpdir(), `pythinker-oauth-mgr-int-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it('login persists token to disk; ensureFresh reads it back', async () => { - const storage = new FileTokenStorage(dir); - const refreshImpl = vi.fn().mockResolvedValue(makeToken({ accessToken: 'refreshed' })); - const mgr = new OAuthManager({ - config, - storage, - now, - requestDeviceImpl: async () => ({ - userCode: 'U', - deviceCode: 'D', - verificationUri: '', - verificationUriComplete: 'https://x', - expiresIn: 600, - interval: 5, - }), - pollDeviceImpl: async (): Promise => ({ - kind: 'success', - token: makeToken({ accessToken: 'fresh-from-login', expiresAt: currentNow + 7200 }), - }), - sleep: async () => {}, - refreshTokenImpl: refreshImpl, - }); - const token = await mgr.login(); - expect(token.accessToken).toBe('fresh-from-login'); - - // New manager instance reads from same storage (simulates restart) - const mgr2 = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - }); - const access = await mgr2.ensureFresh(); - expect(access).toBe('fresh-from-login'); - expect(refreshImpl).not.toHaveBeenCalled(); - }); - - it('logout removes token file', async () => { - const storage = new FileTokenStorage(dir); - await storage.save('pythinker-code', makeToken()); - const mgr = new OAuthManager({ config, storage, now }); - expect(await mgr.hasToken()).toBe(true); - await mgr.logout(); - expect(await mgr.hasToken()).toBe(false); - expect(await storage.load('pythinker-code')).toBeUndefined(); - }); - - it('ensureFresh refreshes and persists to disk', async () => { - const storage = new FileTokenStorage(dir); - await storage.save( - 'pythinker-code', - makeToken({ refreshToken: 'rt-original', expiresAt: currentNow + 100 }), - ); - const refreshImpl = vi.fn().mockResolvedValue( - makeToken({ - accessToken: 'rotated-access', - refreshToken: 'rotated-refresh', - expiresAt: currentNow + 7200, - }), - ); - const mgr = new OAuthManager({ - config, - storage, - now, - refreshTokenImpl: refreshImpl, - }); - await mgr.ensureFresh(); - const persisted = await storage.load('pythinker-code'); - expect(persisted?.accessToken).toBe('rotated-access'); - expect(persisted?.refreshToken).toBe('rotated-refresh'); - }); -}); diff --git a/packages/oauth/test/oauth.test.ts b/packages/oauth/test/oauth.test.ts deleted file mode 100644 index 2626b2e0..00000000 --- a/packages/oauth/test/oauth.test.ts +++ /dev/null @@ -1,729 +0,0 @@ -/** - * OAuth device code flow tests — pure HTTP wrappers against a fake server. - * - * Covers the three endpoint calls: requestDeviceAuthorization, pollDeviceToken, - * refreshAccessToken. Uses a local HTTP server on a dynamic port to exercise - * the real fetch code path. - */ - -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - OAuthConnectionError, - OAuthError, - OAuthUnauthorizedError, - RetryableRefreshError, -} from '../src/errors'; -import { - pollDeviceToken, - refreshAccessToken, - requestDeviceAuthorization, - type RefreshOptions, -} from '../src/oauth'; -import { PYTHINKER_CODE_PLATFORM } from '../src/identity'; -import type { DeviceHeaders, OAuthFlowConfig } from '../src/types'; - -interface FakeResponse { - status: number; - body: string | Record; - /** - * When true, destroy the socket before writing any status / body. - * Used by the "network error retry" test to surface a transport-level - * failure (fetch throws) on the first N attempts. - */ - drop?: boolean; -} - -interface Recorded { - path: string; - method: string; - headers: Record; - body: string; -} - -class FakeOAuthServer { - private server: Server | undefined; - private responses: Map = new Map(); - readonly recorded: Recorded[] = []; - host = ''; - - async start(): Promise { - this.server = createServer((req, res) => { - this.handle(req, res); - }); - await new Promise((resolve) => { - this.server!.listen(0, '127.0.0.1', () => { - resolve(); - }); - }); - const addr = this.server.address(); - if (addr === null || typeof addr === 'string') { - throw new Error('no server address'); - } - this.host = `http://127.0.0.1:${addr.port}`; - } - - async stop(): Promise { - await new Promise((resolve) => { - this.server!.close(() => { - resolve(); - }); - }); - } - - /** Queue a response for the given POST path (FIFO). */ - enqueue(path: string, response: FakeResponse): void { - const key = `POST ${path}`; - const list = this.responses.get(key) ?? []; - list.push(response); - this.responses.set(key, list); - } - - private handle(req: IncomingMessage, res: ServerResponse): void { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => { - const body = Buffer.concat(chunks).toString('utf-8'); - const path = req.url ?? ''; - this.recorded.push({ - path, - method: req.method ?? '', - headers: req.headers as Record, - body, - }); - const key = `${req.method} ${path}`; - const queue = this.responses.get(key); - const next = queue?.shift(); - if (!next) { - res.statusCode = 404; - res.end(JSON.stringify({ error: 'no fake response queued', key })); - return; - } - if (next.drop === true) { - // Destroy the socket so `fetch` rejects with a transport error. - req.socket.destroy(); - return; - } - res.statusCode = next.status; - res.setHeader('content-type', 'application/json'); - res.end(typeof next.body === 'string' ? next.body : JSON.stringify(next.body)); - }); - } -} - -// ── Fixtures ────────────────────────────────────────────────────────── - -let server: FakeOAuthServer; - -const TEST_DEVICE_HEADERS: DeviceHeaders = { - 'X-Msh-Platform': PYTHINKER_CODE_PLATFORM, - 'X-Msh-Version': '0.0.0-test', - 'X-Msh-Device-Name': 'test-device', - 'X-Msh-Device-Model': 'test-model', - 'X-Msh-Os-Version': 'test-os', - 'X-Msh-Device-Id': 'test-device-id', -}; - -function expectNoDeviceHeaders(headers: Record): void { - expect(headers['x-msh-platform']).toBeUndefined(); - expect(headers['x-msh-device-id']).toBeUndefined(); - expect(headers['x-msh-version']).toBeUndefined(); -} - -function flowConfig(): OAuthFlowConfig { - return { - name: 'pythinker-code', - oauthHost: server.host, - clientId: 'test-client-id', - }; -} - -function requestAuth( - config: OAuthFlowConfig = flowConfig(), -): ReturnType { - return requestDeviceAuthorization(config, { deviceHeaders: TEST_DEVICE_HEADERS }); -} - -function pollToken( - config: OAuthFlowConfig, - deviceCode: string, -): ReturnType { - return pollDeviceToken(config, deviceCode, { deviceHeaders: TEST_DEVICE_HEADERS }); -} - -function refreshToken( - config: OAuthFlowConfig, - refreshTokenValue: string, - options: Omit = {}, -): ReturnType { - return refreshAccessToken(config, refreshTokenValue, { - ...options, - deviceHeaders: TEST_DEVICE_HEADERS, - }); -} - -beforeEach(async () => { - server = new FakeOAuthServer(); - await server.start(); -}); - -afterEach(async () => { - await server.stop(); -}); - -// ── requestDeviceAuthorization ──────────────────────────────────────── - -describe('requestDeviceAuthorization', () => { - it('parses a successful response', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'WDJB-MJHT', - device_code: 'devcode123', - verification_uri: 'https://auth.kimi.com/verify', - verification_uri_complete: 'https://auth.kimi.com/verify?user_code=WDJB-MJHT', - expires_in: 600, - interval: 5, - }, - }); - - const auth = await requestAuth(); - expect(auth.userCode).toBe('WDJB-MJHT'); - expect(auth.deviceCode).toBe('devcode123'); - expect(auth.verificationUri).toBe('https://auth.kimi.com/verify'); - expect(auth.verificationUriComplete).toBe('https://auth.kimi.com/verify?user_code=WDJB-MJHT'); - expect(auth.expiresIn).toBe(600); - expect(auth.interval).toBe(5); - }); - - it('posts client_id as form-encoded body', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'U', - device_code: 'D', - verification_uri_complete: 'https://x/y', - expires_in: 60, - interval: 5, - }, - }); - await requestAuth(); - const recorded = server.recorded[0]!; - expect(recorded.headers['content-type']).toContain('application/x-www-form-urlencoded'); - expect(recorded.body).toContain('client_id=test-client-id'); - }); - - it('sends X-Msh-* device headers', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'U', - device_code: 'D', - verification_uri_complete: 'https://x/y', - expires_in: 60, - interval: 5, - }, - }); - await requestAuth(); - const recorded = server.recorded[0]!; - expect(recorded.headers['x-msh-platform']).toBe(PYTHINKER_CODE_PLATFORM); - expect(recorded.headers['x-msh-device-id']).toBe('test-device-id'); - expect(recorded.headers['x-msh-version']).toBe('0.0.0-test'); - expect(recorded.headers['user-agent'] ?? '').not.toContain('pythinker-code-cli'); - }); - - it('omits X-Msh-* device headers when deviceHeaders are absent', async () => { - expect.hasAssertions(); - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'U', - device_code: 'D', - verification_uri_complete: 'https://x/y', - expires_in: 60, - interval: 5, - }, - }); - await requestDeviceAuthorization(flowConfig(), {}); - const recorded = server.recorded[0]!; - expectNoDeviceHeaders(recorded.headers); - }); - - it('defaults interval to 5 when omitted', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'U', - device_code: 'D', - verification_uri_complete: 'https://x/y', - expires_in: 60, - }, - }); - const auth = await requestAuth(); - expect(auth.interval).toBe(5); - }); - - it('throws OAuthError on non-200 response', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 500, - body: { error: 'server_error' }, - }); - await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError); - }); - - // Every renderer hands these URLs straight to the host's "open externally" - // API, so a provider answering with `file:` or an app's custom scheme would - // have the agent launch it. Rejected here, where the response is parsed, - // rather than in each renderer. - it.each([ - ['file:///etc/passwd'], - ['javascript:alert(1)'], - ['vscode://extension/install?id=evil'], - ['not a url'], - ])('rejects a non-HTTPS verification_uri_complete (%s)', async (uri) => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'U', - device_code: 'D', - verification_uri_complete: uri, - expires_in: 60, - interval: 5, - }, - }); - await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError); - }); - - it('rejects a non-HTTPS verification_uri even when the complete one is safe', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'U', - device_code: 'D', - verification_uri: 'file:///etc/passwd', - verification_uri_complete: 'https://auth.kimi.com/verify?user_code=U', - expires_in: 60, - interval: 5, - }, - }); - await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError); - }); - - it('surfaces message fields from failed device authorization responses', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 400, - body: { message: 'device authorization disabled' }, - }); - - await expect(requestAuth()).rejects.toThrow(/device authorization disabled/); - }); - - it('throws when device_code is missing (required-field validation)', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'X', - verification_uri_complete: 'https://x', - expires_in: 60, - interval: 5, - // device_code missing - }, - }); - await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError); - }); - - it('throws when verification_uri_complete is missing (required-field validation)', async () => { - server.enqueue('/api/oauth/device_authorization', { - status: 200, - body: { - user_code: 'X', - device_code: 'D', - expires_in: 60, - interval: 5, - // verification_uri_complete missing - }, - }); - await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError); - }); -}); - -// ── pollDeviceToken ─────────────────────────────────────────────────── - -describe('pollDeviceToken', () => { - it('returns TokenInfo on success (200)', async () => { - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'at-1', - refresh_token: 'rt-1', - expires_in: 3600, - scope: 'read', - token_type: 'Bearer', - }, - }); - - const res = await pollToken(flowConfig(), 'devcode123'); - expect(res.kind).toBe('success'); - if (res.kind !== 'success') throw new Error('expected success'); - expect(res.token.accessToken).toBe('at-1'); - expect(res.token.refreshToken).toBe('rt-1'); - expect(res.token.expiresIn).toBe(3600); - expect(res.token.expiresAt).toBeGreaterThan(Date.now() / 1000); - }); - - it('omits X-Msh-* device headers when deviceHeaders are absent', async () => { - expect.hasAssertions(); - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'at-1', - refresh_token: 'rt-1', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }, - }); - await pollDeviceToken(flowConfig(), 'devcode123', {}); - const recorded = server.recorded[0]!; - expectNoDeviceHeaders(recorded.headers); - }); - - it('returns pending on authorization_pending', async () => { - server.enqueue('/api/oauth/token', { - status: 400, - body: { error: 'authorization_pending' }, - }); - - const res = await pollToken(flowConfig(), 'devcode123'); - expect(res.kind).toBe('pending'); - if (res.kind !== 'pending') throw new Error('expected pending'); - expect(res.errorCode).toBe('authorization_pending'); - }); - - it('returns pending on slow_down', async () => { - server.enqueue('/api/oauth/token', { - status: 400, - body: { error: 'slow_down' }, - }); - const res = await pollToken(flowConfig(), 'devcode123'); - expect(res.kind).toBe('pending'); - }); - - it('returns expired on expired_token', async () => { - server.enqueue('/api/oauth/token', { - status: 400, - body: { error: 'expired_token' }, - }); - const res = await pollToken(flowConfig(), 'devcode123'); - expect(res.kind).toBe('expired'); - }); - - it('returns denied on access_denied', async () => { - server.enqueue('/api/oauth/token', { - status: 400, - body: { error: 'access_denied' }, - }); - const res = await pollToken(flowConfig(), 'devcode123'); - expect(res.kind).toBe('denied'); - }); - - it('throws on 500 server error', async () => { - server.enqueue('/api/oauth/token', { - status: 500, - body: { error: 'server_error' }, - }); - await expect(pollToken(flowConfig(), 'd')).rejects.toBeInstanceOf(OAuthError); - }); - - it('surfaces nested API error messages from failed polling responses', async () => { - server.enqueue('/api/oauth/token', { - status: 400, - body: { error: { code: 'invalid_request', message: 'poll rejected by server' } }, - }); - - await expect(pollToken(flowConfig(), 'd')).rejects.toThrow(/poll rejected by server/); - }); - - it('throws when success response is missing refresh_token (required-field validation)', async () => { - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'at-1', - // refresh_token missing - expires_in: 60, - scope: '', - token_type: 'Bearer', - }, - }); - await expect(pollToken(flowConfig(), 'd')).rejects.toBeInstanceOf(OAuthError); - }); - - it('throws when success response has zero/missing expires_in (required-field validation)', async () => { - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'at-1', - refresh_token: 'rt-1', - scope: '', - token_type: 'Bearer', - // expires_in missing - }, - }); - await expect(pollToken(flowConfig(), 'd')).rejects.toBeInstanceOf(OAuthError); - }); - - it('sends device_code + grant_type=urn:ietf:params:oauth:grant-type:device_code', async () => { - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'a', - refresh_token: 'r', - expires_in: 60, - scope: '', - token_type: 'Bearer', - }, - }); - await pollToken(flowConfig(), 'devcode123'); - const recorded = server.recorded[0]!; - expect(recorded.body).toContain('device_code=devcode123'); - expect(recorded.body).toContain( - 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code', - ); - }); -}); - -// ── refreshAccessToken ──────────────────────────────────────────────── - -describe('refreshAccessToken', () => { - it('returns new TokenInfo on success', async () => { - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'new-at', - refresh_token: 'new-rt', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }, - }); - const token = await refreshToken(flowConfig(), 'old-rt'); - expect(token.accessToken).toBe('new-at'); - expect(token.refreshToken).toBe('new-rt'); - }); - - it('does not retry after a 401 refresh response', async () => { - server.enqueue('/api/oauth/token', { - status: 401, - body: { error: 'invalid_grant', error_description: 'refresh_token expired' }, - }); - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'should-not-reach', - refresh_token: 'r', - expires_in: 60, - scope: '', - token_type: 'Bearer', - }, - }); - await expect( - refreshToken(flowConfig(), 'old-rt', { maxRetries: 3, backoffMs: () => 0 }), - ).rejects.toBeInstanceOf(OAuthUnauthorizedError); - expect(server.recorded.length).toBe(1); - }); - - it('throws OAuthUnauthorizedError on 403', async () => { - server.enqueue('/api/oauth/token', { - status: 403, - body: {}, - }); - await expect(refreshToken(flowConfig(), 'old-rt')).rejects.toBeInstanceOf( - OAuthUnauthorizedError, - ); - }); - - it('surfaces nested API error messages from unauthorized refresh responses', async () => { - server.enqueue('/api/oauth/token', { - status: 401, - body: { error: { message: 'refresh token revoked' } }, - }); - - await expect(refreshToken(flowConfig(), 'old-rt')).rejects.toThrow(/refresh token revoked/); - }); - - it('throws OAuthUnauthorizedError on invalid_grant refresh responses', async () => { - server.enqueue('/api/oauth/token', { - status: 400, - body: { - error: 'invalid_grant', - error_description: 'The provided authorization grant is invalid', - }, - }); - await expect(refreshToken(flowConfig(), 'old-rt')).rejects.toBeInstanceOf( - OAuthUnauthorizedError, - ); - }); - - it.each([429, 500, 502, 503, 504])( - 'retries transient HTTP %i refresh responses until success', - async (status) => { - server.enqueue('/api/oauth/token', { - status, - body: { error_description: 'overloaded' }, - }); - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'a', - refresh_token: 'r', - expires_in: 60, - scope: '', - token_type: 'Bearer', - }, - }); - const token = await refreshToken(flowConfig(), 'old-rt', { - maxRetries: 2, - backoffMs: () => 0, - }); - expect(token.accessToken).toBe('a'); - expect(server.recorded.length).toBe(2); - }, - ); - - it('eventually raises RetryableRefreshError after max retries', async () => { - server.enqueue('/api/oauth/token', { status: 503, body: {} }); - server.enqueue('/api/oauth/token', { status: 503, body: {} }); - await expect( - refreshToken(flowConfig(), 'old-rt', { maxRetries: 2, backoffMs: () => 0 }), - ).rejects.toBeInstanceOf(RetryableRefreshError); - }); - - it('retries on transport-level fetch failure (network retry gap fix)', async () => { - // First attempt: server unreachable. Second: success. - const badConfig: OAuthFlowConfig = { - ...flowConfig(), - // Stop the real server, point at it (will refuse connection), then - // restart for the retry. This is awkward; instead inject via a - // separate flowConfig with closed port for the first call. - oauthHost: 'http://127.0.0.1:1', // reserved port, ECONNREFUSED - }; - // Single attempt against unreachable host should throw (not RetryableRefreshError) - await expect( - refreshToken(badConfig, 'rt', { maxRetries: 1, backoffMs: () => 0 }), - ).rejects.toBeInstanceOf(OAuthConnectionError); - }); - - it('names the transport root cause in the connection error message', async () => { - const badConfig: OAuthFlowConfig = { - ...flowConfig(), - oauthHost: 'http://127.0.0.1:1', - }; - - const failure = await refreshToken(badConfig, 'rt', { - maxRetries: 1, - backoffMs: () => 0, - }).catch((error: unknown) => error); - - expect(failure).toBeInstanceOf(OAuthConnectionError); - const error = failure as OAuthConnectionError; - expect(error.cause).toBeInstanceOf(Error); - const rootCause = error.cause instanceof Error ? error.cause : undefined; - expect(rootCause?.message).toBeTruthy(); - expect(error.message).toContain(rootCause?.message); - }); - - it('sends grant_type=refresh_token + refresh_token', async () => { - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'a', - refresh_token: 'r', - expires_in: 60, - scope: '', - token_type: 'Bearer', - }, - }); - await refreshToken(flowConfig(), 'old-rt-xyz'); - const recorded = server.recorded[0]!; - expect(recorded.body).toContain('grant_type=refresh_token'); - expect(recorded.body).toContain('refresh_token=old-rt-xyz'); - }); - - it('omits X-Msh-* device headers when deviceHeaders are absent', async () => { - expect.hasAssertions(); - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'a', - refresh_token: 'r', - expires_in: 60, - scope: '', - token_type: 'Bearer', - }, - }); - await refreshAccessToken(flowConfig(), 'old-rt-xyz', { maxRetries: 1 }); - const recorded = server.recorded[0]!; - expectNoDeviceHeaders(recorded.headers); - }); - - // ── network error retry / 400 fail-fast ─────────────────────────────── - - it('retries transport-level failures N times, then succeeds', async () => { - // The first two attempts fail with a transport error, the third - // succeeds. We prime the fake server with two force-drop responses - // that destroy the socket before writing headers, which surfaces - // as `fetch` throwing. - server.enqueue('/api/oauth/token', { status: 0, body: '', drop: true }); - server.enqueue('/api/oauth/token', { status: 0, body: '', drop: true }); - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'recovered-at', - refresh_token: 'recovered-rt', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }, - }); - const token = await refreshToken(flowConfig(), 'old-rt', { - maxRetries: 3, - backoffMs: () => 0, - }); - expect(token.accessToken).toBe('recovered-at'); - // All three attempts hit the server (two destroyed + one success) - expect(server.recorded.length).toBe(3); - }); - - it('400 Bad Request fails fast (not retried, non-retryable)', async () => { - // 400 is a client-side fault and must surface immediately as a - // bare OAuthError (never RetryableRefreshError, never retried). - server.enqueue('/api/oauth/token', { - status: 400, - body: { error: 'invalid_request', error_description: 'bad client id' }, - }); - // Second enqueue exists to prove a retry would hit it — if the - // implementation incorrectly retried, the second call would succeed - // and the test would miss the regression. - server.enqueue('/api/oauth/token', { - status: 200, - body: { - access_token: 'should-not-reach', - refresh_token: 'r', - expires_in: 60, - scope: '', - token_type: 'Bearer', - }, - }); - const err = await refreshToken(flowConfig(), 'rt', { - maxRetries: 5, - backoffMs: () => 0, - }).catch((error: unknown) => error); - expect(err).toBeInstanceOf(OAuthError); - expect(err).not.toBeInstanceOf(RetryableRefreshError); - expect(err).not.toBeInstanceOf(OAuthUnauthorizedError); - // Only one request — no retry. - expect(server.recorded.length).toBe(1); - }); -}); diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts index 10f93a12..7cb90352 100644 --- a/packages/oauth/test/open-platform.test.ts +++ b/packages/oauth/test/open-platform.test.ts @@ -10,7 +10,7 @@ import { OPEN_PLATFORMS, OpenPlatformApiError, removeOpenPlatformConfig, - type ManagedKimiConfigShape, + type PlatformConfigShape, } from '../src/open-platform'; function makeModelsResponse(): Response { @@ -155,7 +155,7 @@ describe('filterModelsByPrefix', () => { { id: 'gpt-4', contextLength: 1000, supportsReasoning: false, supportsImageIn: false, supportsVideoIn: false }, ]; - const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-kimi-code').ManagedKimiCodeModelInfo[], platform); + const filtered = filterModelsByPrefix(models as unknown as import('../src/open-platform').PlatformModelInfo[], platform); expect(filtered).toHaveLength(1); expect(filtered[0]?.id).toBe('kimi-k2-0712-preview'); }); @@ -171,7 +171,7 @@ describe('filterModelsByPrefix', () => { { id: 'model-b', contextLength: 2000, supportsReasoning: false, supportsImageIn: false, supportsVideoIn: false }, ]; - const filtered = filterModelsByPrefix(models as unknown as import('../src/managed-kimi-code').ManagedKimiCodeModelInfo[], platform); + const filtered = filterModelsByPrefix(models as unknown as import('../src/open-platform').PlatformModelInfo[], platform); expect(filtered).toHaveLength(2); }); }); @@ -275,7 +275,7 @@ describe('capabilitiesForModel', () => { describe('applyOpenPlatformConfig', () => { it('writes provider, models, and defaults', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: {}, }; const platform = getOpenPlatformById('moonshot-cn')!; @@ -311,7 +311,6 @@ describe('applyOpenPlatformConfig', () => { }); expect(config.defaultModel).toBe('moonshot-cn/kimi-k2-0712-preview'); expect(config.defaultThinking).toBe(true); - expect(config.services).toBeUndefined(); }); it('persists the picked effort, and overwrites a previous one when thinking is off', () => { @@ -320,7 +319,7 @@ describe('applyOpenPlatformConfig', () => { { id: 'kimi-k2-0712-preview', contextLength: 256000, supportsReasoning: true, supportsImageIn: false, supportsVideoIn: false }, ]; - const picked: ManagedKimiConfigShape = { providers: {} }; + const picked: PlatformConfigShape = { providers: {} }; applyOpenPlatformConfig(picked, { platform, models, @@ -333,7 +332,7 @@ describe('applyOpenPlatformConfig', () => { // session reopens at the default no matter what the user chose. expect(picked.thinking?.effort).toBe('medium'); - const turnedOff: ManagedKimiConfigShape = { providers: {}, thinking: { effort: 'high' } }; + const turnedOff: PlatformConfigShape = { providers: {}, thinking: { effort: 'high' } }; applyOpenPlatformConfig(turnedOff, { platform, models, @@ -350,7 +349,7 @@ describe('applyOpenPlatformConfig', () => { }); it('clears stale models for the same provider', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-old' }, }, @@ -379,7 +378,7 @@ describe('applyOpenPlatformConfig', () => { describe('removeOpenPlatformConfig', () => { it('removes provider, its models, and defaultModel when matched', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, 'other': { type: 'pythinker', baseUrl: 'https://other.test/v1', apiKey: 'sk-other' }, @@ -401,7 +400,7 @@ describe('removeOpenPlatformConfig', () => { }); it('leaves defaultModel intact when it belongs to another provider', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: { 'moonshot-cn': { type: 'pythinker', baseUrl: 'https://api.moonshot.cn/v1', apiKey: 'sk-test' }, }, diff --git a/packages/oauth/test/openai-codex-oauth.test.ts b/packages/oauth/test/openai-codex-oauth.test.ts index 42893f35..0eb31c75 100644 --- a/packages/oauth/test/openai-codex-oauth.test.ts +++ b/packages/oauth/test/openai-codex-oauth.test.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { createServer as createNetServer } from 'node:net'; import { describe, expect, it, vi } from 'vitest'; -import type { ManagedKimiConfigShape } from '../src/managed-kimi-code'; +import type { PlatformConfigShape } from '../src/open-platform'; import { applyOpenAICodexOAuthConfig, buildOpenAICodexAuthorizeUrl, @@ -202,7 +202,7 @@ describe('openai-codex-oauth', () => { }); const config = { providers: {}, - } as ManagedKimiConfigShape; + } as PlatformConfigShape; const models = CODEX_MODELS_RESPONSE.models.map((model) => ({ id: model.slug, contextLength: model.context_window, @@ -247,7 +247,7 @@ describe('openai-codex-oauth', () => { }); it('uses the selected model highest supported effort when max is unavailable', () => { - const config: ManagedKimiConfigShape = { + const config: PlatformConfigShape = { providers: {}, thinking: { mode: 'auto', effort: 'low' }, }; diff --git a/packages/oauth/test/refresh-threshold.test.ts b/packages/oauth/test/refresh-threshold.test.ts deleted file mode 100644 index 7f2bbb9f..00000000 --- a/packages/oauth/test/refresh-threshold.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * defaultRefreshThreshold — three boundary cases. - * - * Formula (oauth-manager.ts:39-44): - * threshold = max(MIN_REFRESH_THRESHOLD_SECONDS, expiresIn * 0.5) - * where MIN_REFRESH_THRESHOLD_SECONDS = 300 and 0 / negative expiresIn - * fall back to MIN (the implementation short-circuits on `> 0`). - */ - -import { describe, expect, it } from 'vitest'; - -import { defaultRefreshThreshold } from '../src/oauth-manager'; - -describe('defaultRefreshThreshold — boundary cases', () => { - it('returns expiresIn * 0.5 when ratio exceeds the 300s minimum (expiresIn=1800 → 900)', () => { - // 1800 * 0.5 = 900 > 300. - expect(defaultRefreshThreshold(1800)).toBe(900); - }); - - it('clamps to the 300s minimum when expiresIn * 0.5 falls below (expiresIn=500 → 300)', () => { - // 500 * 0.5 = 250 < 300, so the floor wins. - expect(defaultRefreshThreshold(500)).toBe(300); - }); - - it('falls back to the 300s minimum when expiresIn is 0 (expiresIn=0 → 300)', () => { - // A pathological token with no lifetime still refreshes on a fixed - // cadence; never produces a zero threshold. - expect(defaultRefreshThreshold(0)).toBe(300); - }); -}); diff --git a/packages/oauth/test/storage.test.ts b/packages/oauth/test/storage.test.ts deleted file mode 100644 index f92e9d15..00000000 --- a/packages/oauth/test/storage.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * FileTokenStorage tests — round-trip persistence + permission checks. - * - * Scope guards: tokens never leak to process.env or other files; permission - * 0600 is enforced; corrupted files return undefined rather than throwing. - */ - -import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { FileTokenStorage } from '../src/storage'; -import type { TokenInfo } from '../src/types'; - -function makeTmpDir(): string { - const dir = join( - tmpdir(), - `pythinker-storage-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function sampleToken(overrides: Partial = {}): TokenInfo { - return { - accessToken: 'at-abc', - refreshToken: 'rt-xyz', - expiresAt: 1_700_000_000, - scope: 'read write', - tokenType: 'Bearer', - expiresIn: 3600, - ...overrides, - }; -} - -describe('FileTokenStorage', () => { - let dir: string; - let storage: FileTokenStorage; - - beforeEach(() => { - dir = makeTmpDir(); - storage = new FileTokenStorage(dir); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it('returns undefined when no token exists', async () => { - expect(await storage.load('pythinker-code')).toBeUndefined(); - }); - - it('round-trips a token via save/load', async () => { - const token = sampleToken(); - await storage.save('pythinker-code', token); - const loaded = await storage.load('pythinker-code'); - expect(loaded).toEqual(token); - }); - - it('persists tokens in snake_case JSON (Python-compatible)', async () => { - const token = sampleToken(); - await storage.save('pythinker-code', token); - const raw = readFileSync(join(dir, 'pythinker-code.json'), 'utf-8'); - const parsed = JSON.parse(raw) as Record; - expect(parsed['access_token']).toBe('at-abc'); - expect(parsed['refresh_token']).toBe('rt-xyz'); - expect(parsed['expires_at']).toBe(1_700_000_000); - expect(parsed['token_type']).toBe('Bearer'); - expect(parsed['expires_in']).toBe(3600); - expect(parsed['accessToken']).toBeUndefined(); - }); - - it('writes the credentials file with mode 0600', async () => { - await storage.save('pythinker-code', sampleToken()); - const stat = statSync(join(dir, 'pythinker-code.json')); - // eslint-disable-next-line no-bitwise - expect(stat.mode & 0o777).toBe(0o600); - }); - - it('remove() deletes the file; load() then returns undefined', async () => { - await storage.save('pythinker-code', sampleToken()); - await storage.remove('pythinker-code'); - expect(await storage.load('pythinker-code')).toBeUndefined(); - }); - - it('remove() is idempotent when file is absent', async () => { - await expect(storage.remove('never-existed')).resolves.toBeUndefined(); - }); - - it('save() overwrites an existing token atomically', async () => { - await storage.save('pythinker-code', sampleToken({ accessToken: 'first' })); - await storage.save('pythinker-code', sampleToken({ accessToken: 'second' })); - const loaded = await storage.load('pythinker-code'); - expect(loaded?.accessToken).toBe('second'); - }); - - it('load() returns undefined on corrupt JSON (does not throw)', async () => { - const file = join(dir, 'pythinker-code.json'); - writeFileSync(file, '{ not json', 'utf-8'); - chmodSync(file, 0o600); - expect(await storage.load('pythinker-code')).toBeUndefined(); - }); - - it('load() returns undefined on malformed payload (not a dict)', async () => { - const file = join(dir, 'pythinker-code.json'); - writeFileSync(file, '["array", "instead"]', 'utf-8'); - chmodSync(file, 0o600); - expect(await storage.load('pythinker-code')).toBeUndefined(); - }); - - it('load() tolerates missing numeric fields by defaulting to 0', async () => { - const file = join(dir, 'pythinker-code.json'); - writeFileSync(file, JSON.stringify({ access_token: 'a', refresh_token: 'r' }), 'utf-8'); - chmodSync(file, 0o600); - const token = await storage.load('pythinker-code'); - expect(token?.expiresAt).toBe(0); - expect(token?.expiresIn).toBe(0); - }); - - it('list() returns all stored token names', async () => { - await storage.save('pythinker-code', sampleToken()); - await storage.save('other-provider', sampleToken()); - const names = await storage.list(); - expect(names.toSorted()).toEqual(['other-provider', 'pythinker-code']); - }); - - it('list() ignores non-JSON files in the credentials dir', async () => { - await storage.save('pythinker-code', sampleToken()); - writeFileSync(join(dir, 'pythinker-code.lock'), 'lock', 'utf-8'); - writeFileSync(join(dir, 'readme.txt'), 'readme', 'utf-8'); - const names = await storage.list(); - expect(names).toEqual(['pythinker-code']); - }); - - it('creates the credentials dir with mode 0700 if missing', async () => { - const freshDir = join(dir, 'nested', 'sub'); - const s = new FileTokenStorage(freshDir); - await s.save('pythinker-code', sampleToken()); - const stat = statSync(freshDir); - // eslint-disable-next-line no-bitwise - expect(stat.mode & 0o777).toBe(0o700); - }); - - it('refuses path-traversal names on save (B1)', async () => { - await expect(storage.save('../../etc/passwd', sampleToken())).rejects.toThrow( - /Invalid token name/, - ); - }); - - it('refuses path-traversal names on load', async () => { - await expect(storage.load('../etc/passwd')).rejects.toThrow(/Invalid token name/); - }); - - it('refuses path-traversal names on remove', async () => { - await expect(storage.remove('../etc/passwd')).rejects.toThrow(/Invalid token name/); - }); - - it('refuses leading-dot names (hidden file abuse)', async () => { - await expect(storage.save('.hidden', sampleToken())).rejects.toThrow(/Invalid token name/); - }); - - it('refuses empty name', async () => { - await expect(storage.save('', sampleToken())).rejects.toThrow(/Invalid token name/); - }); - - // ── atomic save leaves no .tmp sibling ──────────────────────────────── - - it('save() leaves no *.tmp.* sibling once the rename completes', async () => { - // Atomic save must clean up its temp artefact after rename. Uses - // `target.tmp..` then renameSync; this test asserts the - // resulting directory contains only the canonical file. - await storage.save('pythinker-code', sampleToken()); - const { readdirSync } = await import('node:fs'); - const entries = readdirSync(dir); - const tmps = entries.filter((name) => name.startsWith('pythinker-code.json.tmp.')); - expect(tmps).toEqual([]); - expect(entries).toContain('pythinker-code.json'); - }); - - it('save() + load() preserves expires_in and expires_at roundtrip', async () => { - // The wire format records both `expires_at` and `expires_in`; the - // load path must restore both fields without loss. - const token = sampleToken({ expiresAt: 1_800_000_000, expiresIn: 7200 }); - await storage.save('pythinker-code', token); - const loaded = await storage.load('pythinker-code'); - expect(loaded?.expiresAt).toBe(1_800_000_000); - expect(loaded?.expiresIn).toBe(7200); - }); - - it('load() of a wire payload missing scope/token_type uses safe defaults', async () => { - // A legacy file written without the optional `scope` / `token_type` - // fields must still load; the defaults come from `tokenFromWire`. - const file = join(dir, 'pythinker-code.json'); - writeFileSync( - file, - JSON.stringify({ - access_token: 'a', - refresh_token: 'r', - expires_at: 1, - expires_in: 60, - }), - 'utf-8', - ); - chmodSync(file, 0o600); - const loaded = await storage.load('pythinker-code'); - expect(loaded?.accessToken).toBe('a'); - expect(loaded?.refreshToken).toBe('r'); - // Defaults should be strings (empty / 'Bearer'), never undefined. - expect(typeof loaded?.scope).toBe('string'); - expect(typeof loaded?.tokenType).toBe('string'); - }); -}); diff --git a/packages/oauth/test/toolkit.test.ts b/packages/oauth/test/toolkit.test.ts deleted file mode 100644 index e988e88e..00000000 --- a/packages/oauth/test/toolkit.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { join } from 'node:path'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { - applyManagedKimiCodeConfig, - KIMI_CODE_PROVIDER_NAME, - PythinkerOAuthToolkit, - resolveKimiCodeOAuthKey, - resolvePythinkerTokenStorageName, - type ManagedKimiConfigShape, - type TokenInfo, - type TokenStorage, -} from '../src'; - -class MemoryTokenStorage implements TokenStorage { - readonly tokens = new Map(); - - async load(name: string): Promise { - return this.tokens.get(name); - } - - async save(name: string, token: TokenInfo): Promise { - this.tokens.set(name, token); - } - - async remove(name: string): Promise { - this.tokens.delete(name); - } - - async list(): Promise { - return [...this.tokens.keys()]; - } -} - -function token(accessToken: string): TokenInfo { - return { - accessToken, - refreshToken: `refresh-${accessToken}`, - expiresAt: 10_000, - scope: '', - tokenType: 'Bearer', - expiresIn: 3600, - }; -} - -const TEST_IDENTITY = { - userAgentProduct: 'pythinker-code-cli', - version: '0.0.0-test', -} as const; - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -function managedModelsResponse(): Response { - return new Response( - JSON.stringify({ - data: [ - { - id: 'pythinker-for-coding', - context_length: 262144, - supports_reasoning: true, - }, - ], - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); -} - -function fetchInputUrl(input: unknown): string { - if (typeof input === 'string') return input; - if (input instanceof URL) return input.href; - if (input instanceof Request) return input.url; - throw new TypeError('expected fetch input to be a string, URL, or Request'); -} - -describe('resolvePythinkerTokenStorageName', () => { - it('maps config oauth keys to the file storage token name', () => { - expect( - resolvePythinkerTokenStorageName({ - providerName: KIMI_CODE_PROVIDER_NAME, - oauthKey: 'oauth/kimi-code', - }), - ).toBe('kimi-code'); - expect(resolvePythinkerTokenStorageName({ oauthKey: 'kimi-code' })).toBe('kimi-code'); - }); - - it('rejects unsupported providers and unsafe token keys', () => { - expect(() => - resolvePythinkerTokenStorageName({ - providerName: 'custom', - oauthKey: 'kimi-code', - }), - ).toThrow(/No OAuth manager/); - expect(() => resolvePythinkerTokenStorageName({ oauthKey: '../pythinker-code' })).toThrow(/Invalid/); - }); -}); - -describe('PythinkerOAuthToolkit', () => { - it('can be constructed without host identity', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('kimi-code', token('access-1')); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - storage, - now: () => 100, - }); - - await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('access-1'); - }); - - it('reports status and exposes a bearer token provider', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('kimi-code', token('access-1')); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - }); - - await expect(toolkit.status()).resolves.toEqual({ - providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }], - }); - await expect(toolkit.tokenProvider().getAccessToken()).resolves.toBe('access-1'); - }); - - it('resolves bearer token providers using the configured oauth key', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('custom-pythinker-code', token('custom-access')); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - }); - - await expect( - toolkit - .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }) - .getAccessToken(), - ).resolves.toBe('custom-access'); - }); - - it('refreshes configured bearer token refs against their OAuth host', async () => { - const storage = new MemoryTokenStorage(); - const oauthHost = 'https://auth.dev.example.test'; - const oauthKey = resolveKimiCodeOAuthKey({ - oauthHost, - baseUrl: 'https://api.dev.example.test/coding/v1', - }); - storage.tokens.set(resolvePythinkerTokenStorageName({ oauthKey }), { - ...token('expired-dev-access'), - expiresAt: 100, - }); - const fetchImpl = vi.fn(async (input: unknown, init?: RequestInit) => { - expect(fetchInputUrl(input)).toBe(`${oauthHost}/api/oauth/token`); - if (typeof init?.body !== 'string') throw new TypeError('expected form body'); - expect(new URLSearchParams(init.body).get('grant_type')).toBe('refresh_token'); - return new Response( - JSON.stringify({ - access_token: 'rotated-dev-access', - refresh_token: 'rotated-dev-refresh', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchImpl); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 1_000, - flowConfig: { - name: 'kimi-code', - oauthHost: 'https://auth.kimi.com', - clientId: 'test-client-id', - }, - }); - - await expect( - toolkit - .tokenProvider(KIMI_CODE_PROVIDER_NAME, { key: oauthKey, oauthHost }) - .getAccessToken(), - ).resolves.toBe('rotated-dev-access'); - }); - - it('does not reuse a cached OAuth manager across different hosts for the same token key', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('custom-pythinker-code', { - ...token('expired-custom-access'), - expiresAt: 100, - }); - const requests: string[] = []; - const fetchImpl = vi.fn(async (input: unknown) => { - requests.push(fetchInputUrl(input)); - return new Response( - JSON.stringify({ - access_token: `rotated-${String(requests.length)}`, - refresh_token: `refresh-${String(requests.length)}`, - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchImpl); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 1_000, - flowConfig: { - name: 'kimi-code', - oauthHost: 'https://auth.kimi.com', - clientId: 'test-client-id', - }, - }); - - await expect( - toolkit - .tokenProvider(KIMI_CODE_PROVIDER_NAME, { - key: 'oauth/custom-pythinker-code', - oauthHost: 'https://auth.one.test/', - }) - .getAccessToken({ force: true }), - ).resolves.toBe('rotated-1'); - await expect( - toolkit - .tokenProvider(KIMI_CODE_PROVIDER_NAME, { - key: 'oauth/custom-pythinker-code', - oauthHost: 'https://auth.two.test', - }) - .getAccessToken({ force: true }), - ).resolves.toBe('rotated-2'); - - expect(requests).toEqual([ - 'https://auth.one.test/api/oauth/token', - 'https://auth.two.test/api/oauth/token', - ]); - }); - - it('returns the cached access token without refreshing it', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('kimi-code', { - ...token('cached-access'), - expiresAt: 1, - }); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 10_000, - }); - - await expect(toolkit.getCachedAccessToken()).resolves.toBe('cached-access'); - }); - - it('resolves cached access tokens using the configured oauth key', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('custom-pythinker-code', token('custom-cached-access')); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - }); - - await expect( - toolkit.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME, { key: 'oauth/custom-pythinker-code' }), - ).resolves.toBe('custom-cached-access'); - }); - - it('returns undefined when no cached access token exists', async () => { - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage: new MemoryTokenStorage(), - now: () => 100, - }); - - await expect(toolkit.getCachedAccessToken()).resolves.toBeUndefined(); - }); - - it('provisions managed config after login when an adapter is configured', async () => { - const storage = new MemoryTokenStorage(); - const write = vi.fn(); - const fetchImpl = vi.fn(async () => managedModelsResponse()) as unknown as typeof fetch; - const config = { providers: {} }; - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - fetchImpl, - configAdapter: { - read: () => config, - write, - apply: (target, input) => { - target.providers[KIMI_CODE_PROVIDER_NAME] = { - type: 'pythinker', - apiKey: '', - }; - return { - defaultModel: `pythinker-code/${input.models[0]?.id ?? 'unknown'}`, - defaultThinking: true, - }; - }, - }, - }); - - storage.tokens.set('kimi-code', token('access-1')); - await expect(toolkit.login()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - provision: { - defaultModel: 'pythinker-code/pythinker-for-coding', - }, - }); - expect(write).toHaveBeenCalledWith(config); - }); - - it.each([401, 402])( - 'force-refreshes a stored token when managed model provisioning rejects cached auth with HTTP %i', - async (status) => { - const storage = new MemoryTokenStorage(); - const write = vi.fn(); - const onDeviceCode = vi.fn(); - const config = { providers: {} }; - const oauthHost = 'https://auth.test'; - const oauthKey = resolveKimiCodeOAuthKey({ oauthHost }); - storage.tokens.set(resolvePythinkerTokenStorageName({ oauthKey }), token('stale-access')); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { message: 'The API Key appears to be invalid or may have expired.' }, - }), - { status, headers: { 'Content-Type': 'application/json' } }, - ), - ) - .mockResolvedValueOnce(managedModelsResponse()); - const fetchImpl = fetchMock as unknown as typeof fetch; - const oauthFetch = vi.fn(async (_input: unknown, init?: RequestInit) => { - if (typeof init?.body !== 'string') throw new TypeError('expected form body'); - const body = new URLSearchParams(init.body); - if (body.get('grant_type') !== 'refresh_token') { - throw new Error(`unexpected OAuth grant: ${body.get('grant_type') ?? ''}`); - } - return new Response( - JSON.stringify({ - access_token: 'rotated-access', - refresh_token: 'rotated-refresh', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', oauthFetch); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - fetchImpl, - flowConfig: { - name: 'kimi-code', - oauthHost, - clientId: 'test-client-id', - }, - configAdapter: { - read: () => config, - write, - apply: (target, input) => { - target.providers[KIMI_CODE_PROVIDER_NAME] = { - type: 'pythinker', - apiKey: '', - }; - return { - defaultModel: `pythinker-code/${input.models[0]?.id ?? 'unknown'}`, - defaultThinking: true, - }; - }, - }, - }); - - await expect(toolkit.login(undefined, { onDeviceCode })).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - provision: { - defaultModel: 'pythinker-code/pythinker-for-coding', - }, - }); - expect(fetchMock).toHaveBeenCalledTimes(2); - const firstModelRequest = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; - const secondModelRequest = fetchMock.mock.calls[1]?.[1] as RequestInit | undefined; - expect(new Headers(firstModelRequest?.headers).get('authorization')).toBe( - 'Bearer stale-access', - ); - expect(new Headers(secondModelRequest?.headers).get('authorization')).toBe( - 'Bearer rotated-access', - ); - expect(oauthFetch).toHaveBeenCalledTimes(1); - expect(onDeviceCode).not.toHaveBeenCalled(); - expect(write).toHaveBeenCalledWith(config); - }, - ); - - it('uses a scoped credential slot for non-default OAuth login environments', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('kimi-code', token('prod-access')); - const config: ManagedKimiConfigShape = { providers: {} }; - const devBaseUrl = 'https://api.dev.example.test/coding/v1'; - const devOauthHost = 'https://auth.dev.example.test'; - const devOauthKey = resolveKimiCodeOAuthKey({ - oauthHost: devOauthHost, - baseUrl: devBaseUrl, - }); - const devStorageName = resolvePythinkerTokenStorageName({ oauthKey: devOauthKey }); - const write = vi.fn(); - const fetchMock = vi.fn(async (_input: unknown, _init?: RequestInit) => - managedModelsResponse(), - ); - const oauthFetch = vi.fn(async (_input: unknown, init?: RequestInit) => { - if (typeof init?.body !== 'string') throw new TypeError('expected form body'); - const body = new URLSearchParams(init.body); - if (body.get('grant_type') === 'urn:ietf:params:oauth:grant-type:device_code') { - return new Response( - JSON.stringify({ - access_token: 'dev-access', - refresh_token: 'dev-refresh', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - return new Response( - JSON.stringify({ - user_code: 'WDJB-MJHT', - device_code: 'device-code', - verification_uri: `${devOauthHost}/verify`, - verification_uri_complete: `${devOauthHost}/verify?user_code=WDJB-MJHT`, - expires_in: 600, - interval: 1, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', oauthFetch); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - fetchImpl: fetchMock as unknown as typeof fetch, - flowConfig: { - name: 'kimi-code', - oauthHost: devOauthHost, - clientId: 'test-client-id', - }, - configAdapter: { - read: () => config, - write, - apply: applyManagedKimiCodeConfig, - }, - }); - - await expect(toolkit.login(undefined, { baseUrl: devBaseUrl })).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - }); - expect(oauthFetch).toHaveBeenCalledTimes(2); - expect(storage.tokens.get('kimi-code')?.accessToken).toBe('prod-access'); - expect(storage.tokens.get(devStorageName)?.accessToken).toBe('dev-access'); - expect(config.providers[KIMI_CODE_PROVIDER_NAME]?.oauth).toEqual({ - storage: 'file', - key: devOauthKey, - oauthHost: devOauthHost, - }); - const modelRequest = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; - expect(new Headers(modelRequest?.headers).get('authorization')).toBe('Bearer dev-access'); - expect(write).toHaveBeenCalledWith(config); - }); - - it('starts a new device flow when the stored refresh token is invalid', async () => { - const storage = new MemoryTokenStorage(); - const oauthHost = 'https://auth.test'; - const oauthKey = resolveKimiCodeOAuthKey({ oauthHost }); - const storageName = resolvePythinkerTokenStorageName({ oauthKey }); - storage.tokens.set(storageName, { - ...token('stale-access'), - refreshToken: 'revoked-refresh', - expiresAt: 101, - }); - const onDeviceCode = vi.fn(); - const fetchImpl = vi.fn(async (_input: unknown, init?: RequestInit) => { - if (typeof init?.body !== 'string') throw new TypeError('expected form body'); - const body = new URLSearchParams(init.body); - if (body.get('grant_type') === 'refresh_token') { - return new Response( - JSON.stringify({ - error: 'invalid_grant', - error_description: 'The provided authorization grant is invalid', - }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, - ); - } - if (body.get('grant_type') === 'urn:ietf:params:oauth:grant-type:device_code') { - return new Response( - JSON.stringify({ - access_token: 'fresh-access', - refresh_token: 'fresh-refresh', - expires_in: 3600, - scope: '', - token_type: 'Bearer', - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - return new Response( - JSON.stringify({ - user_code: 'WDJB-MJHT', - device_code: 'device-code', - verification_uri: 'https://auth.test/verify', - verification_uri_complete: 'https://auth.test/verify?user_code=WDJB-MJHT', - expires_in: 600, - interval: 1, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }) as unknown as typeof fetch; - vi.stubGlobal('fetch', fetchImpl); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - flowConfig: { - name: 'kimi-code', - oauthHost, - clientId: 'test-client-id', - }, - }); - - await expect(toolkit.login(undefined, { onDeviceCode })).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - }); - expect(onDeviceCode).toHaveBeenCalledTimes(1); - expect((await storage.load(storageName))?.accessToken).toBe('fresh-access'); - }); - - it('removes managed config on logout when an adapter supports cleanup', async () => { - const storage = new MemoryTokenStorage(); - storage.tokens.set('kimi-code', token('access-1')); - const config = { providers: { [KIMI_CODE_PROVIDER_NAME]: { type: 'pythinker' } } }; - const write = vi.fn(); - const remove = vi.fn(); - const toolkit = new PythinkerOAuthToolkit({ - homeDir: join('/tmp', 'pythinker-oauth-toolkit-test'), - identity: TEST_IDENTITY, - storage, - now: () => 100, - configAdapter: { - read: () => config, - write, - apply: () => ({ defaultModel: 'pythinker-code/pythinker-for-coding', defaultThinking: true }), - remove, - }, - }); - - await expect(toolkit.logout()).resolves.toMatchObject({ - providerName: KIMI_CODE_PROVIDER_NAME, - ok: true, - }); - expect(remove).toHaveBeenCalledWith(config); - expect(write).toHaveBeenCalledWith(config); - await expect(storage.load('kimi-code')).resolves.toBeUndefined(); - }); -}); diff --git a/packages/protocol/src/__tests__/rest-auth.test.ts b/packages/protocol/src/__tests__/rest-auth.test.ts index aca0bd8f..a4e53f74 100644 --- a/packages/protocol/src/__tests__/rest-auth.test.ts +++ b/packages/protocol/src/__tests__/rest-auth.test.ts @@ -1,27 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { - authSummarySchema, - managedProviderStatusSchema, - type AuthSummary, -} from '../rest/auth'; +import { authSummarySchema, type AuthSummary } from '../rest/auth'; describe('authSummarySchema', () => { const emptyState: AuthSummary = { ready: false, providers_count: 0, default_model: null, - managed_provider: null, }; const readyState: AuthSummary = { ready: true, providers_count: 1, default_model: 'pythinker-k2', - managed_provider: { - name: 'pythinker-code-oauth', - status: 'authenticated', - }, }; it('round-trips an empty (unprovisioned) state', () => { @@ -29,66 +20,25 @@ describe('authSummarySchema', () => { expect(parsed.ready).toBe(false); expect(parsed.providers_count).toBe(0); expect(parsed.default_model).toBeNull(); - expect(parsed.managed_provider).toBeNull(); }); - it('round-trips a ready state with managed provider', () => { + it('round-trips a ready state', () => { const parsed = authSummarySchema.parse(readyState); expect(parsed.ready).toBe(true); expect(parsed.providers_count).toBe(1); expect(parsed.default_model).toBe('pythinker-k2'); - expect(parsed.managed_provider).toEqual({ - name: 'pythinker-code-oauth', - status: 'authenticated', - }); }); - it.each(['authenticated', 'expired', 'revoked', 'unauthenticated'] as const)( - 'accepts managed_provider.status = %s', - (status) => { - const parsed = managedProviderStatusSchema.parse(status); - expect(parsed).toBe(status); - }, - ); - - it('rejects an unknown managed_provider.status', () => { - const bad = { - ...readyState, - managed_provider: { name: 'pythinker-code-oauth', status: 'pending' }, - }; - expect(authSummarySchema.safeParse(bad).success).toBe(false); - }); - - it('rejects missing ready', () => { - const { ready: _omit, ...rest } = emptyState; - expect(authSummarySchema.safeParse(rest).success).toBe(false); + it('rejects a negative providers_count', () => { + expect(() => authSummarySchema.parse({ ...emptyState, providers_count: -1 })).toThrow(/invalid|expected|Invalid/i); }); - it('rejects missing providers_count', () => { - const { providers_count: _omit, ...rest } = emptyState; - expect(authSummarySchema.safeParse(rest).success).toBe(false); + it('rejects a non-integer providers_count', () => { + expect(() => authSummarySchema.parse({ ...emptyState, providers_count: 1.5 })).toThrow(/invalid|expected|Invalid/i); }); - it('rejects missing default_model', () => { + it('rejects a missing default_model rather than defaulting it', () => { const { default_model: _omit, ...rest } = emptyState; - expect(authSummarySchema.safeParse(rest).success).toBe(false); - }); - - it('rejects missing managed_provider', () => { - const { managed_provider: _omit, ...rest } = emptyState; - expect(authSummarySchema.safeParse(rest).success).toBe(false); - }); - - it('rejects negative providers_count', () => { - const bad = { ...emptyState, providers_count: -1 }; - expect(authSummarySchema.safeParse(bad).success).toBe(false); - }); - - it('rejects empty managed_provider.name', () => { - const bad = { - ...readyState, - managed_provider: { name: '', status: 'authenticated' as const }, - }; - expect(authSummarySchema.safeParse(bad).success).toBe(false); + expect(() => authSummarySchema.parse(rest)).toThrow(/invalid|expected|Invalid/i); }); }); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index accd0207..aec2a614 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -22,7 +22,6 @@ export * from './modelCatalog'; export * from './rest/meta'; export * from './rest/auth'; -export * from './rest/oauth'; export * from './rest/session'; export * from './rest/snapshot'; export * from './rest/workspace'; diff --git a/packages/protocol/src/rest/auth.ts b/packages/protocol/src/rest/auth.ts index 8edb0888..37c17047 100644 --- a/packages/protocol/src/rest/auth.ts +++ b/packages/protocol/src/rest/auth.ts @@ -1,32 +1,12 @@ /** * GET /v1/auth - * Reply: AuthSummary { - * ready, - * providers_count, - * default_model, - * managed_provider - * } + * Reply: AuthSummary { ready, providers_count, default_model } */ import { z } from 'zod'; -export const managedProviderStatusSchema = z.enum([ - 'authenticated', - 'expired', - 'revoked', - 'unauthenticated', -]); -export type ManagedProviderStatus = z.infer; - -export const managedProviderSummarySchema = z.object({ - name: z.string().min(1), - status: managedProviderStatusSchema, -}); -export type ManagedProviderSummary = z.infer; - export const authSummarySchema = z.object({ ready: z.boolean(), providers_count: z.number().int().nonnegative(), default_model: z.string().nullable(), - managed_provider: managedProviderSummarySchema.nullable(), }); export type AuthSummary = z.infer; diff --git a/packages/protocol/src/rest/modelCatalog.ts b/packages/protocol/src/rest/modelCatalog.ts index dd0262c5..e0044414 100644 --- a/packages/protocol/src/rest/modelCatalog.ts +++ b/packages/protocol/src/rest/modelCatalog.ts @@ -3,8 +3,6 @@ import { z } from 'zod'; import { modelCatalogItemSchema, providerCatalogItemSchema, - providerRefreshChangeSchema, - providerRefreshFailureSchema, } from '../modelCatalog'; export const listModelsResponseSchema = z.object({ @@ -26,11 +24,3 @@ export const setDefaultModelResponseSchema = z.object({ }); export type SetDefaultModelResponse = z.infer; -export const refreshOAuthProviderModelsResponseSchema = z.object({ - changed: z.array(providerRefreshChangeSchema), - unchanged: z.array(z.string().min(1)), - failed: z.array(providerRefreshFailureSchema), -}); -export type RefreshOAuthProviderModelsResponse = z.infer< - typeof refreshOAuthProviderModelsResponseSchema ->; diff --git a/packages/protocol/src/rest/oauth.ts b/packages/protocol/src/rest/oauth.ts deleted file mode 100644 index 0703db41..00000000 --- a/packages/protocol/src/rest/oauth.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * POST /v1/oauth/login body: { provider? } data: OAuthFlowStart - * GET /v1/oauth/login query: { provider? } data: OAuthFlowStatus | null - * DELETE /v1/oauth/login query: { provider? } data: { cancelled, status } - * POST /v1/oauth/logout body: { provider? } data: { logged_out, provider } - */ -import { z } from 'zod'; - -import { isoDateTimeSchema } from '../time'; - -export const oauthFlowStatusEnum = z.enum([ - 'pending', - 'authenticated', - 'denied', - 'expired', - 'cancelled', -]); -export type OAuthFlowStatus = z.infer; - -export const oauthLoginStartRequestSchema = z.object({ - provider: z.string().min(1).optional(), -}); -export type OAuthLoginStartRequest = z.infer; - -export const oauthFlowStartSchema = z.object({ - flow_id: z.string().min(1), - provider: z.string().min(1), - verification_uri: z.string().url(), - verification_uri_complete: z.string().url(), - user_code: z.string().min(1), - expires_in: z.number().int().positive(), - interval: z.number().int().positive(), - status: z.literal('pending'), - expires_at: isoDateTimeSchema, -}); -export type OAuthFlowStart = z.infer; - -export const oauthFlowSnapshotSchema = z.object({ - flow_id: z.string().min(1), - provider: z.string().min(1), - status: oauthFlowStatusEnum, - verification_uri: z.string().url(), - verification_uri_complete: z.string().url(), - user_code: z.string().min(1), - expires_in: z.number().int().positive(), - expires_at: isoDateTimeSchema, - interval: z.number().int().positive(), - resolved_at: isoDateTimeSchema.optional(), - error_message: z.string().optional(), -}); -export type OAuthFlowSnapshot = z.infer; - -export const oauthLoginQuerySchema = z.object({ - provider: z.string().min(1).optional(), -}); -export type OAuthLoginQuery = z.infer; - -export const oauthLoginCancelResponseSchema = z.object({ - cancelled: z.boolean(), - status: oauthFlowStatusEnum, -}); -export type OAuthLoginCancelResponse = z.infer; - -export const oauthLogoutRequestSchema = z.object({ - provider: z.string().min(1).optional(), -}); -export type OAuthLogoutRequest = z.infer; - -export const oauthLogoutResponseSchema = z.object({ - logged_out: z.literal(true), - provider: z.string().min(1), -}); -export type OAuthLogoutResponse = z.infer; diff --git a/packages/server/src/routes/modelCatalog.ts b/packages/server/src/routes/modelCatalog.ts index e95e8a91..df7cb4af 100644 --- a/packages/server/src/routes/modelCatalog.ts +++ b/packages/server/src/routes/modelCatalog.ts @@ -5,7 +5,6 @@ import { getProviderResponseSchema, listModelsResponseSchema, listProvidersResponseSchema, - refreshOAuthProviderModelsResponseSchema, setDefaultModelResponseSchema, } from '@pythoughts/protocol'; import { IModelCatalogService, ModelNotFoundError, ProviderNotFoundError, type IInstantiationService } from '@pythoughts/agent-core'; @@ -131,27 +130,6 @@ export function registerModelCatalogRoutes( listProvidersRoute.handler as Parameters[2], ); - const refreshOAuthProvidersRoute = defineRoute( - { - method: 'POST', - path: '/providers:refresh_oauth', - success: { data: refreshOAuthProviderModelsResponseSchema }, - description: 'Refresh OAuth-backed provider model metadata', - tags: ['providers'], - operationId: 'refreshOAuthProviderModels', - }, - async (req, reply) => { - const result = await ix.invokeFunction((a) => - a.get(IModelCatalogService).refreshOAuthProviderModels(), - ); - reply.send(okEnvelope(result, req.id)); - }, - ); - app.post( - refreshOAuthProvidersRoute.path, - refreshOAuthProvidersRoute.options, - refreshOAuthProvidersRoute.handler as Parameters[2], - ); const getProviderRoute = defineRoute( { diff --git a/packages/server/src/routes/oauth.ts b/packages/server/src/routes/oauth.ts deleted file mode 100644 index 70af08e6..00000000 --- a/packages/server/src/routes/oauth.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * `/v1/oauth/*` REST routes. - * - * POST /v1/oauth/login start a device-code flow → OAuthFlowStart - * GET /v1/oauth/login poll current flow state → OAuthFlowSnapshot | null - * DELETE /v1/oauth/login cancel pending flow → { cancelled, status } - * POST /v1/oauth/logout logout → { logged_out, provider } - * - * **Polling contract**: the frontend opens `verification_uri_complete` in a - * browser tab, then polls `GET /v1/oauth/login` at the `interval` seconds - * returned in the start response. When `status` flips to `'authenticated'`, - * stop polling and hit `GET /v1/auth` to see `ready: true`. - * - * **No bare flow_id in URL**: only one flow is in-flight per provider. The - * frontend has the flow_id from the start response — it uses it client-side - * to detect "the flow I started got superseded" (matching the snapshot's - * flow_id against its own captured value). - */ - -import { - oauthFlowSnapshotSchema, - oauthFlowStartSchema, - oauthLoginCancelResponseSchema, - oauthLoginQuerySchema, - oauthLoginStartRequestSchema, - oauthLogoutRequestSchema, - oauthLogoutResponseSchema, -} from '@pythoughts/protocol'; -import { IOAuthService, type IInstantiationService } from '@pythoughts/agent-core'; -import { z } from 'zod'; - -import { okEnvelope } from '../envelope'; -import { defineRoute } from '../middleware/defineRoute'; - -/** - * Structural Fastify subset — same shape as `meta.ts` / `auth.ts` so the - * generic-mismatch with the server's pino-typed FastifyInstance doesn't - * bleed into this file. - */ -interface RouteHost { - get( - path: string, - options: { preHandler?: unknown[]; schema?: Record }, - handler: ( - req: { id: string; query: unknown }, - reply: { send(payload: unknown): void }, - ) => Promise | void, - ): unknown; - post( - path: string, - options: { preHandler?: unknown[]; schema?: Record }, - handler: ( - req: { id: string; body: unknown }, - reply: { send(payload: unknown): void }, - ) => Promise | void, - ): unknown; - delete( - path: string, - options: { preHandler?: unknown[]; schema?: Record }, - handler: ( - req: { id: string; query: unknown }, - reply: { send(payload: unknown): void }, - ) => Promise | void, - ): unknown; -} - -/** - * `GET /v1/oauth/login` returns either a snapshot or `null` (no flow yet). - * Wrap in a nullable z.object so the generated OpenAPI knows about both. - */ -const oauthFlowSnapshotOrNullSchema = z.union([ - oauthFlowSnapshotSchema, - z.null(), -]); - -export function registerOAuthRoutes(app: RouteHost, ix: IInstantiationService): void { - // POST /oauth/login — start device flow ---------------------------------- - const loginStartRoute = defineRoute( - { - method: 'POST', - path: '/oauth/login', - body: oauthLoginStartRequestSchema, - success: { data: oauthFlowStartSchema }, - description: 'Start an OAuth device-code flow', - tags: ['auth'], - }, - async (req, reply) => { - const result = await ix.invokeFunction((a) => - a.get(IOAuthService).startLogin(req.body.provider), - ); - reply.send(okEnvelope(result, req.id)); - }, - ); - - app.post( - loginStartRoute.path, - loginStartRoute.options, - loginStartRoute.handler as Parameters[2], - ); - - // GET /oauth/login — poll current flow state ----------------------------- - const loginPollRoute = defineRoute( - { - method: 'GET', - path: '/oauth/login', - querystring: oauthLoginQuerySchema, - success: { data: oauthFlowSnapshotOrNullSchema }, - description: 'Poll the current OAuth device-code flow', - tags: ['auth'], - }, - async (req, reply) => { - const snapshot = ix.invokeFunction((a) => - a.get(IOAuthService).getFlow(req.query.provider), - ); - reply.send(okEnvelope(snapshot ?? null, req.id)); - }, - ); - - app.get( - loginPollRoute.path, - loginPollRoute.options, - loginPollRoute.handler as Parameters[2], - ); - - // DELETE /oauth/login — cancel pending flow ------------------------------ - const loginCancelRoute = defineRoute( - { - method: 'DELETE', - path: '/oauth/login', - querystring: oauthLoginQuerySchema, - success: { data: oauthLoginCancelResponseSchema }, - description: 'Cancel the current OAuth device-code flow', - tags: ['auth'], - }, - async (req, reply) => { - const result = await ix.invokeFunction((a) => - a.get(IOAuthService).cancelLogin(req.query.provider), - ); - reply.send(okEnvelope(result, req.id)); - }, - ); - - app.delete( - loginCancelRoute.path, - loginCancelRoute.options, - loginCancelRoute.handler as Parameters[2], - ); - - // POST /oauth/logout ----------------------------------------------------- - const logoutRoute = defineRoute( - { - method: 'POST', - path: '/oauth/logout', - body: oauthLogoutRequestSchema, - success: { data: oauthLogoutResponseSchema }, - description: 'Logout the managed OAuth provider', - tags: ['auth'], - }, - async (req, reply) => { - const result = await ix.invokeFunction((a) => - a.get(IOAuthService).logout(req.body.provider), - ); - reply.send(okEnvelope(result, req.id)); - }, - ); - - app.post( - logoutRoute.path, - logoutRoute.options, - logoutRoute.handler as Parameters[2], - ); -} diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index 1a21c392..aa04e7f1 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -12,7 +12,6 @@ import { registerFsRoutes } from './fs'; import { registerMessagesRoutes } from './messages'; import { registerMetaRoute } from './meta'; import { registerModelCatalogRoutes } from './modelCatalog'; -import { registerOAuthRoutes } from './oauth'; import { registerPromptsRoutes } from './prompts'; import { registerQuestionsRoutes } from './questions'; import { registerSessionsRoutes } from './sessions'; @@ -70,7 +69,6 @@ export async function registerApiV1Routes( apiV1 as unknown as Parameters[0], ix, ); - registerOAuthRoutes(apiV1 as unknown as Parameters[0], ix); registerModelCatalogRoutes( apiV1 as unknown as Parameters[0], ix, diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 4208184b..c7bc5f09 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -1,4 +1,4 @@ -import { InstantiationService, resolveConfigPath, resolvePythinkerHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IOAuthService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@pythoughts/agent-core'; +import { InstantiationService, resolveConfigPath, resolvePythinkerHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@pythoughts/agent-core'; import { ErrorCode, createAsyncApiDocument } from '@pythoughts/protocol'; import Fastify from 'fastify'; import { promises as fspPromises } from 'node:fs'; @@ -209,7 +209,6 @@ export async function startServer(opts: ServerStartOptions): Promise { }); }); - it('surfaces managed_provider.unauthenticated when config has managed:kimi-code but no cached token', async () => { - seedConfig( - [ - '[providers."managed:kimi-code"]', - 'type = "pythinker"', - 'base_url = "https://example/v1"', - '', - '[providers."managed:kimi-code".oauth]', - 'storage = "file"', - 'key = "oauth/kimi-code"', - '', - ].join('\n'), - ); - const r = await bootDaemon(); - const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth' }); - const env = envelopeOf(res.json()); - const summary = authSummarySchema.parse(env.data); - expect(summary.managed_provider).toEqual({ - name: 'managed:kimi-code', - status: 'unauthenticated', - }); - // ready is still false — no default_model, even though provider exists - expect(summary.ready).toBe(false); - }); }); /* -------------------------------------------------------------------- */ diff --git a/packages/server/test/model-catalog.e2e.test.ts b/packages/server/test/model-catalog.e2e.test.ts index a4e9feb3..9d6b2784 100644 --- a/packages/server/test/model-catalog.e2e.test.ts +++ b/packages/server/test/model-catalog.e2e.test.ts @@ -239,18 +239,6 @@ describe('model/provider catalog routes', () => { setDefaultModel: async () => { throw new Error('unused'); }, - refreshOAuthProviderModels: async () => ({ - changed: [ - { - provider_id: 'managed:kimi-code', - provider_name: 'Pythinker Code', - added: 1, - removed: 0, - }, - ], - unchanged: [], - failed: [], - }), }; const r = await bootDaemon([[IModelCatalogService, stub]]); diff --git a/packages/server/test/oauth.e2e.test.ts b/packages/server/test/oauth.e2e.test.ts deleted file mode 100644 index 236f0346..00000000 --- a/packages/server/test/oauth.e2e.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * `/v1/oauth/*` REST endpoints e2e tests (P2.7). - * - * **Strategy**: replace the real `IOAuthService` via a startup DI override, - * so the routes go through their - * full Fastify validation + envelope wrapping but never touch a real OAuth - * host. We keep the `OAuthServiceImpl` itself out of scope here — the - * services-package unit test (`oauth-service.test.ts`) covers its internal - * state machine end-to-end. - * - * Coverage: - * - POST /oauth/login returns 200 + envelope { code:0, data: OAuthFlowStart } - * - GET /oauth/login returns 200 + envelope { code:0, data: null } before start - * - GET /oauth/login returns the snapshot after start - * - DELETE /oauth/login returns { cancelled, status } - * - POST /oauth/logout returns { logged_out: true, provider } - * - body / query schema validation → 40001 - * - device_code never appears in any response body - */ - -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { pino } from 'pino'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - oauthFlowSnapshotSchema, - oauthFlowStartSchema, -} from '@pythoughts/protocol'; -import { - IOAuthService, -} from '@pythoughts/agent-core'; -import type { - OAuthFlowSnapshot, - OAuthFlowStart, - OAuthLoginCancelResponse, - OAuthLogoutResponse, -} from '@pythoughts/protocol'; - -import { IRestGateway, startServer, type RunningServer } from '../src'; - -let tmpDir: string; -let lockPath: string; -let bridgeHome: string; -let server: RunningServer | undefined; - -beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'pythinker-server-oauth-test-')); - lockPath = join(tmpDir, 'lock'); - bridgeHome = mkdtempSync(join(tmpdir(), 'pythinker-server-oauth-home-')); -}); - -afterEach(async () => { - try { - await server?.close(); - } catch { - // ignore - } - server = undefined; - rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); -}); - -interface StubOAuth { - startLogin: (provider?: string) => Promise; - getFlow: (provider?: string) => OAuthFlowSnapshot | undefined; - cancelLogin: (provider?: string) => Promise; - logout: (provider?: string) => Promise; - calls: { - start: Array<{ provider: string | undefined }>; - get: Array<{ provider: string | undefined }>; - cancel: Array<{ provider: string | undefined }>; - logout: Array<{ provider: string | undefined }>; - }; -} - -/** Build a stub service with scripted responses. */ -function makeStub(scripted: { - start?: OAuthFlowStart; - snapshot?: OAuthFlowSnapshot | undefined; - cancel?: OAuthLoginCancelResponse; - logout?: OAuthLogoutResponse; -}): StubOAuth { - const calls = { - start: [] as Array<{ provider: string | undefined }>, - get: [] as Array<{ provider: string | undefined }>, - cancel: [] as Array<{ provider: string | undefined }>, - logout: [] as Array<{ provider: string | undefined }>, - }; - const defaultStart: OAuthFlowStart = { - flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:kimi-code', - verification_uri: 'https://example.com/device', - verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', - user_code: 'PYTH-1234', - expires_in: 900, - interval: 5, - status: 'pending', - expires_at: '2026-06-05T08:00:00.000Z', - }; - return { - calls, - startLogin: async (provider) => { - calls.start.push({ provider }); - return scripted.start ?? defaultStart; - }, - getFlow: (provider) => { - calls.get.push({ provider }); - return scripted.snapshot; - }, - cancelLogin: async (provider) => { - calls.cancel.push({ provider }); - return scripted.cancel ?? { cancelled: false, status: 'cancelled' }; - }, - logout: async (provider) => { - calls.logout.push({ provider }); - return scripted.logout ?? { logged_out: true, provider: 'managed:kimi-code' }; - }, - }; -} - -async function bootDaemon(stub: StubOAuth): Promise { - server = await startServer({ - host: '127.0.0.1', - port: 0, - lockPath, - logger: pino({ level: 'silent' }), - coreProcessOptions: { homeDir: bridgeHome }, - serviceOverrides: [[IOAuthService, stub]], - }); - return server; -} - -function appOf(r: RunningServer): { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; -} { - return r.services.invokeFunction((a) => { - const gw = a.get(IRestGateway); - return gw.app as unknown as { - inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; - }; - }); -} - -function envelopeOf(body: unknown): { - code: number; - msg: string; - data: T | null; - request_id: string; - details?: unknown; -} { - return body as { - code: number; - msg: string; - data: T | null; - request_id: string; - details?: unknown; - }; -} - -describe('POST /api/v1/oauth/login (P2.7)', () => { - it('returns 200 + envelope { code:0, data: OAuthFlowStart }', async () => { - const stub = makeStub({}); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'POST', - url: '/api/v1/oauth/login', - payload: {}, - }); - expect(res.statusCode).toBe(200); - const env = envelopeOf(res.json()); - expect(env.code).toBe(0); - const data = oauthFlowStartSchema.parse(env.data); - expect(data.flow_id).toBe('oauth_01ABCDEFGH'); - expect(data.verification_uri_complete).toBe( - 'https://example.com/device?user_code=PYTH-1234', - ); - expect(stub.calls.start).toEqual([{ provider: undefined }]); - }); - - it('passes through the optional provider field', async () => { - const stub = makeStub({}); - const r = await bootDaemon(stub); - await appOf(r).inject({ - method: 'POST', - url: '/api/v1/oauth/login', - payload: { provider: 'managed:other' }, - }); - expect(stub.calls.start[0]?.provider).toBe('managed:other'); - }); - - it('rejects an invalid provider field with 40001', async () => { - const stub = makeStub({}); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'POST', - url: '/api/v1/oauth/login', - payload: { provider: 123 }, - }); - const env = envelopeOf(res.json()); - expect(env.code).toBe(40001); - }); -}); - -describe('GET /api/v1/oauth/login (P2.7)', () => { - it('returns 200 + envelope { code:0, data: null } when no flow is registered', async () => { - const stub = makeStub({ snapshot: undefined }); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'GET', - url: '/api/v1/oauth/login', - }); - expect(res.statusCode).toBe(200); - const env = envelopeOf(res.json()); - expect(env.code).toBe(0); - expect(env.data).toBeNull(); - }); - - it('returns the snapshot when present', async () => { - const snap: OAuthFlowSnapshot = { - flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:kimi-code', - status: 'pending', - verification_uri: 'https://example.com/device', - verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', - user_code: 'PYTH-1234', - expires_in: 900, - expires_at: '2026-06-05T08:00:00.000Z', - interval: 5, - }; - const stub = makeStub({ snapshot: snap }); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'GET', - url: '/api/v1/oauth/login', - }); - const env = envelopeOf(res.json()); - expect(env.code).toBe(0); - const parsed = oauthFlowSnapshotSchema.parse(env.data); - expect(parsed.status).toBe('pending'); - // Wire must not leak device_code. - expect(JSON.stringify(env)).not.toContain('device_code'); - }); - - it('reflects terminal-state snapshots', async () => { - const snap: OAuthFlowSnapshot = { - flow_id: 'oauth_01ABCDEFGH', - provider: 'managed:kimi-code', - status: 'authenticated', - verification_uri: 'https://example.com/device', - verification_uri_complete: 'https://example.com/device?user_code=PYTH-1234', - user_code: 'PYTH-1234', - expires_in: 900, - expires_at: '2026-06-05T08:00:00.000Z', - interval: 5, - resolved_at: '2026-06-05T07:50:00.000Z', - }; - const stub = makeStub({ snapshot: snap }); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'GET', - url: '/api/v1/oauth/login', - }); - const env = envelopeOf(res.json()); - const parsed = oauthFlowSnapshotSchema.parse(env.data); - expect(parsed.status).toBe('authenticated'); - expect(parsed.resolved_at).toBe('2026-06-05T07:50:00.000Z'); - }); -}); - -describe('DELETE /api/v1/oauth/login (P2.7)', () => { - it('returns { cancelled:true, status:cancelled } on a pending flow', async () => { - const stub = makeStub({ - cancel: { cancelled: true, status: 'cancelled' }, - }); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'DELETE', - url: '/api/v1/oauth/login', - }); - const env = envelopeOf(res.json()); - expect(env.code).toBe(0); - expect(env.data).toEqual({ cancelled: true, status: 'cancelled' }); - }); - - it('idempotently reports the current status on terminal flows', async () => { - const stub = makeStub({ - cancel: { cancelled: false, status: 'authenticated' }, - }); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'DELETE', - url: '/api/v1/oauth/login', - }); - const env = envelopeOf(res.json()); - expect(env.data).toEqual({ cancelled: false, status: 'authenticated' }); - }); -}); - -describe('POST /api/v1/oauth/logout (P2.7)', () => { - it('returns { logged_out:true, provider }', async () => { - const stub = makeStub({}); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'POST', - url: '/api/v1/oauth/logout', - payload: {}, - }); - const env = envelopeOf(res.json()); - expect(env.code).toBe(0); - expect(env.data).toEqual({ - logged_out: true, - provider: 'managed:kimi-code', - }); - expect(stub.calls.logout).toHaveLength(1); - }); - - it('passes the provider field through', async () => { - const stub = makeStub({ - logout: { logged_out: true, provider: 'managed:other' }, - }); - const r = await bootDaemon(stub); - const res = await appOf(r).inject({ - method: 'POST', - url: '/api/v1/oauth/logout', - payload: { provider: 'managed:other' }, - }); - const env = envelopeOf(res.json()); - expect(env.data?.provider).toBe('managed:other'); - }); -}); From 1643794c9a73c443e28a03fd95d8e9fd3c34e5c7 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 04:34:35 -0400 Subject: [PATCH 2/8] refactor(config): drop the oauth credential reference from provider config Nothing writes an oauth ref now that logins end in an api key: OpenAI Codex stores its access token as the provider apiKey with the refresh token under `source`, and every other path is a plain key. Remove the field, the schema, its TOML round-trip, the ProviderManager auth resolver it fed, and the mutual-exclusion rule that only existed to keep it apart from static keys. The web client loses the device-code login dialog and the /oauth REST calls behind it; logging in there opens the provider manager, which adds API-key and catalog providers. --- .changeset/provider-neutral-login-platform.md | 8 + apps/pythinker-code/src/cli/sub/provider.ts | 1 - apps/pythinker-code/test/cli/provider.test.ts | 10 +- apps/pythinker-web/src/App.vue | 42 +- apps/pythinker-web/src/api/daemon/client.ts | 54 -- apps/pythinker-web/src/api/daemon/wire.ts | 28 - apps/pythinker-web/src/api/types.ts | 19 - .../src/components/LoginDialog.vue | 621 ------------------ .../src/components/ProviderManager.vue | 14 - .../src/composables/usePythinkerWebClient.ts | 67 -- docs/configuration/config-files.md | 14 +- docs/configuration/env-vars.md | 16 +- docs/configuration/overrides.md | 2 +- docs/configuration/providers.md | 8 +- packages/agent-core/src/config/schema.ts | 18 +- packages/agent-core/src/config/toml.ts | 24 +- packages/agent-core/src/index.ts | 2 - .../src/services/config/configService.ts | 4 +- .../src/session/provider-manager.ts | 94 +-- .../test/agent/compaction/full.test.ts | 95 --- packages/agent-core/test/agent/turn.test.ts | 351 ---------- .../agent-core/test/config/configs.test.ts | 50 -- .../test/harness/runtime-provider.test.ts | 93 --- packages/agent-core/test/loop/retry.test.ts | 56 -- .../pythinker-harness-config-smoke.ts | 26 +- packages/node-sdk/src/types.ts | 1 - packages/server-e2e/test/client.test.ts | 1 - .../server-e2e/test/refresh-replay.test.ts | 1 - 28 files changed, 46 insertions(+), 1674 deletions(-) create mode 100644 .changeset/provider-neutral-login-platform.md delete mode 100644 apps/pythinker-web/src/components/LoginDialog.vue 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/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index 51ff55ef..717c769a 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -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/test/cli/provider.test.ts b/apps/pythinker-code/test/cli/provider.test.ts index 503ce52c..da01e9b4 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/); 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-web/src/App.vue b/apps/pythinker-web/src/App.vue index d3711e12..6fff47b3 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; + showProviders.value = true; } 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..2944f3fa 100644 --- a/apps/pythinker-web/src/api/daemon/wire.ts +++ b/apps/pythinker-web/src/api/daemon/wire.ts @@ -390,44 +390,16 @@ export interface WireConfig { // Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout) // --------------------------------------------------------------------------- -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/ProviderManager.vue b/apps/pythinker-web/src/components/ProviderManager.vue index be193c48..0c8ba1a4 100644 --- a/apps/pythinker-web/src/components/ProviderManager.vue +++ b/apps/pythinker-web/src/components/ProviderManager.vue @@ -25,7 +25,6 @@ const emit = defineEmits<{ refresh: [id: string]; delete: [id: string]; /** Open the login dialog for the given platform (OAuth flow) */ - openLogin: [platform: string]; close: []; }>(); @@ -196,19 +195,6 @@ function statusLabel(status: AppProvider['status']): string {