Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/provider-neutral-login-platform.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 3 additions & 19 deletions apps/pythinker-code/src/auth/terminal-login-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<PlatformSelection | undefined> {
let catalog = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON) ?? {};
Expand Down Expand Up @@ -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<string, ModelAlias> = {};
for (const m of models) {
modelDict[`${platform.id}/${m.id}`] = managedModelToAlias(platform.id, m);
Expand Down Expand Up @@ -257,7 +242,6 @@ export function createTerminalLoginUi(
log.error(message);
},
showLoginProgressSpinner,
showLoginAuthorizationPrompt,
promptPlatformSelection,
promptApiKey,
promptModelSelectionForOpenPlatform,
Expand Down
7 changes: 0 additions & 7 deletions apps/pythinker-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 0 additions & 10 deletions apps/pythinker-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 3 additions & 4 deletions apps/pythinker-code/src/cli/sub/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
CustomRegistryApiError,
fetchCustomRegistry,
type CustomRegistrySource,
type ManagedKimiConfigShape,
type PlatformConfigShape,
} from '@pythoughts/pythinker-code-oauth';
import {
catalogConnectionWire,
Expand Down Expand Up @@ -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 {
Expand All @@ -531,7 +531,6 @@ function providerSourceLabel(provider: PythinkerConfig['providers'][string]): st
return `modelsDev(${source['url']})`;
}
}
if (provider.oauth !== undefined) return 'oauth';
return 'inline';
}

Expand Down
12 changes: 1 addition & 11 deletions apps/pythinker-code/src/cli/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
3 changes: 0 additions & 3 deletions apps/pythinker-code/src/constant/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 4 additions & 26 deletions apps/pythinker-code/src/tui/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -78,26 +76,11 @@ export async function connectCatalogProvider(
}

export async function handleLogoutCommand(host: SlashCommandHost): Promise<void> {
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,
Expand All @@ -117,11 +100,7 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>
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();
Expand All @@ -135,6 +114,5 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>
}

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}.`);
}
2 changes: 0 additions & 2 deletions apps/pythinker-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -179,7 +178,6 @@ export interface SlashCommandHost {

// UI
showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle;
showProgressSpinner(label: string): LoginProgressSpinnerHandle;

// Theme
Expand Down
80 changes: 3 additions & 77 deletions apps/pythinker-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { release as osRelease, type as osType } from 'node:os';
import { join, relative } from 'node:path';

import type {
Expand All @@ -19,65 +18,20 @@ 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';

// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------

export async function handleFeedbackCommand(host: SlashCommandHost): Promise<void> {
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);
}

// ---------------------------------------------------------------------------
Expand All @@ -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;
Expand All @@ -112,15 +62,12 @@ export function showCost(host: SlashCommandHost): void {

export async function showUsage(host: SlashCommandHost): Promise<void> {
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);
Expand Down Expand Up @@ -171,10 +118,7 @@ export async function showContextReport(
}

export async function showStatusReport(host: SlashCommandHost): Promise<void> {
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,
Expand All @@ -193,8 +137,6 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
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);
Expand Down Expand Up @@ -456,19 +398,3 @@ async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeS
}
}

async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUsageResult | undefined> {
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 } };
}
Loading
Loading