Skip to content

Commit 42da384

Browse files
authored
refactor(oauth): make the login platform layer provider-neutral (#34)
1 parent a0fc153 commit 42da384

167 files changed

Lines changed: 540 additions & 14336 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@pythoughts/pythinker-code-sdk": minor
3+
"@pythoughts/pythinker-code": minor
4+
---
5+
6+
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.
7+
8+
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.

apps/pythinker-code/src/auth/terminal-login-ui.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515

1616
import { isCancel, log, password, select, spinner, text } from '@clack/prompts';
1717
import {
18-
type DeviceAuthorization,
19-
type ManagedKimiCodeModelInfo,
18+
type PlatformModelInfo,
2019
type OpenPlatformDefinition,
2120
} from '@pythoughts/pythinker-code-oauth';
2221
import {
@@ -93,20 +92,6 @@ export function createTerminalLoginUi(
9392
};
9493
}
9594

96-
function showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle {
97-
const url = auth.verificationUriComplete || auth.verificationUri;
98-
// Print the manual fallback before attempting to open the user's browser
99-
// so headless/browser-opener failures never hide the URL and code needed
100-
// to complete login.
101-
log.info(`Go to: ${url}`);
102-
log.info(`Enter code: ${auth.userCode}`);
103-
try {
104-
openUrl(url);
105-
} catch {
106-
// Best effort only: the manual fallback has already been printed.
107-
}
108-
return showLoginProgressSpinner('Waiting for authorization…');
109-
}
11095

11196
async function promptPlatformSelection(): Promise<PlatformSelection | undefined> {
11297
let catalog = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON) ?? {};
@@ -214,9 +199,9 @@ export function createTerminalLoginUi(
214199
}
215200

216201
async function promptModelSelectionForOpenPlatform(
217-
models: readonly ManagedKimiCodeModelInfo[],
202+
models: readonly PlatformModelInfo[],
218203
platform: OpenPlatformDefinition,
219-
): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined> {
204+
): Promise<{ model: PlatformModelInfo; effort: string } | undefined> {
220205
const modelDict: Record<string, ModelAlias> = {};
221206
for (const m of models) {
222207
modelDict[`${platform.id}/${m.id}`] = managedModelToAlias(platform.id, m);
@@ -257,7 +242,6 @@ export function createTerminalLoginUi(
257242
log.error(message);
258243
},
259244
showLoginProgressSpinner,
260-
showLoginAuthorizationPrompt,
261245
promptPlatformSelection,
262246
promptApiKey,
263247
promptModelSelectionForOpenPlatform,

apps/pythinker-code/src/cli/run-prompt.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,6 @@ export async function runPrompt(
8080
uiMode: PROMPT_UI_MODE,
8181
skillDirs: opts.skillsDirs,
8282
telemetry: telemetryClient,
83-
onOAuthRefresh: (outcome) => {
84-
if (outcome.success) {
85-
track('oauth_refresh', { success: true });
86-
return;
87-
}
88-
track('oauth_refresh', { success: false, reason: outcome.reason });
89-
},
9083
});
9184
log.info('pythinker-code starting', {
9285
version,

apps/pythinker-code/src/cli/run-shell.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,6 @@ export async function runShell(
6363
homeDir: telemetryBootstrap.homeDir,
6464
identity: createPythinkerCodeHostIdentity(version),
6565
telemetry: telemetryClient,
66-
onOAuthRefresh: (outcome) => {
67-
if (outcome.success) {
68-
track('oauth_refresh', { success: true });
69-
return;
70-
}
71-
track('oauth_refresh', {
72-
success: false,
73-
reason: outcome.reason,
74-
});
75-
},
7666
});
7767
log.info('pythinker-code starting', {
7868
version,

apps/pythinker-code/src/cli/sub/provider.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
CustomRegistryApiError,
1818
fetchCustomRegistry,
1919
type CustomRegistrySource,
20-
type ManagedKimiConfigShape,
20+
type PlatformConfigShape,
2121
} from '@pythoughts/pythinker-code-oauth';
2222
import {
2323
catalogConnectionWire,
@@ -517,8 +517,8 @@ function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string
517517
return undefined;
518518
}
519519

520-
function asManaged(config: PythinkerConfig): ManagedKimiConfigShape {
521-
return config as unknown as ManagedKimiConfigShape;
520+
function asManaged(config: PythinkerConfig): PlatformConfigShape {
521+
return config as unknown as PlatformConfigShape;
522522
}
523523

524524
function providerSourceLabel(provider: PythinkerConfig['providers'][string]): string {
@@ -531,7 +531,6 @@ function providerSourceLabel(provider: PythinkerConfig['providers'][string]): st
531531
return `modelsDev(${source['url']})`;
532532
}
533533
}
534-
if (provider.oauth !== undefined) return 'oauth';
535534
return 'inline';
536535
}
537536

apps/pythinker-code/src/cli/telemetry.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { createPythinkerDeviceId, KIMI_CODE_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth';
1+
import { createPythinkerDeviceId } from '@pythoughts/pythinker-code-oauth';
22
import {
3-
PythinkerAuthFacade,
43
loadRuntimeConfigSafe,
54
resolveConfigPath,
65
resolvePythinkerHome,
@@ -17,7 +16,6 @@ import {
1716

1817
import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app';
1918

20-
import { createPythinkerCodeHostIdentity } from './version';
2119

2220
export interface CliTelemetryBootstrap {
2321
readonly homeDir: string;
@@ -54,8 +52,6 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions):
5452
version: options.version,
5553
uiMode: options.uiMode,
5654
model: options.model ?? options.config.defaultModel,
57-
getAccessToken: async () =>
58-
(await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
5955
});
6056
if (options.bootstrap.firstLaunch) {
6157
options.harness.track('first_launch');
@@ -88,11 +84,6 @@ export function initializeServerTelemetry(
8884
const bootstrap = createCliTelemetryBootstrap();
8985
const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir });
9086
const config = readServerTelemetryConfig(configPath);
91-
const auth = new PythinkerAuthFacade({
92-
homeDir: bootstrap.homeDir,
93-
configPath,
94-
identity: createPythinkerCodeHostIdentity(options.version),
95-
});
9687

9788
initializeTelemetry({
9889
homeDir: bootstrap.homeDir,
@@ -102,7 +93,6 @@ export function initializeServerTelemetry(
10293
version: options.version,
10394
uiMode: WEB_UI_MODE,
10495
model: config.defaultModel,
105-
getAccessToken: async () => (await auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
10696
});
10797

10898
return {

apps/pythinker-code/src/constant/app.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@ export const PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
3333
export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner';
3434
export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json';
3535

36-
// Managed Pythinker auth provider key shared with OAuth/SDK config.
37-
export { KIMI_CODE_PROVIDER_NAME as DEFAULT_OAUTH_PROVIDER_NAME } from '@pythoughts/pythinker-code-oauth';
38-
3936
// SDK/core error code that tells the TUI to show a login-required startup
4037
// notice. Derived from sdk's ErrorCodes so a future rename in core
4138
// auto-propagates instead of silently breaking the startup recovery path.

apps/pythinker-code/src/tui/commands/auth.ts

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
} from '@pythoughts/pythinker-code-sdk';
77

88
import type { ChoiceOption } from '../components/dialogs/choice-picker';
9-
import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/pythinker-tui';
109
import {
1110
promptApiKey,
1211
promptLogoutProviderSelection,
@@ -42,7 +41,6 @@ function loginUiFromHost(host: SlashCommandHost): LoginUi {
4241
host.showError(message);
4342
},
4443
showLoginProgressSpinner: (label) => host.showLoginProgressSpinner(label),
45-
showLoginAuthorizationPrompt: (auth) => host.showLoginAuthorizationPrompt(auth),
4644
promptPlatformSelection: () => promptPlatformSelection(host),
4745
promptApiKey: (platformName, subtitleLines, options) =>
4846
options === undefined
@@ -78,26 +76,11 @@ export async function connectCatalogProvider(
7876
}
7977

8078
export async function handleLogoutCommand(host: SlashCommandHost): Promise<void> {
81-
const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME);
82-
const hasOAuthToken = oauthStatus.providers.some(
83-
(p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken,
84-
);
8579
const config = await host.harness.getConfig();
86-
const hasManagedRemnant =
87-
hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined;
88-
const apiKeyProviderIds = Object.keys(config.providers ?? {})
89-
.filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME)
90-
.toSorted();
80+
const providerIds = Object.keys(config.providers ?? {}).toSorted();
9181

9282
const options: ChoiceOption[] = [];
93-
if (hasManagedRemnant) {
94-
options.push({
95-
value: DEFAULT_OAUTH_PROVIDER_NAME,
96-
label: PRODUCT_NAME,
97-
description: 'OAuth login',
98-
});
99-
}
100-
for (const id of apiKeyProviderIds) {
83+
for (const id of providerIds) {
10184
const baseUrl = config.providers[id]?.baseUrl;
10285
options.push({
10386
value: id,
@@ -117,11 +100,7 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>
117100
const target = await promptLogoutProviderSelection(host, options, currentProvider);
118101
if (target === undefined) return;
119102

120-
if (target === DEFAULT_OAUTH_PROVIDER_NAME) {
121-
await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME);
122-
} else {
123-
await host.harness.removeProvider(target);
124-
}
103+
await host.harness.removeProvider(target);
125104

126105
if (target === currentProvider) {
127106
await host.authFlow.refreshConfigAfterLogout();
@@ -135,6 +114,5 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>
135114
}
136115

137116
host.track('logout', { provider: target });
138-
const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target;
139-
host.showStatus(`Logged out from ${label}.`);
117+
host.showStatus(`Logged out from ${target}.`);
140118
}

apps/pythinker-code/src/tui/commands/dispatch.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Component, Focusable } from '@earendil-works/pi-tui';
2-
import type { DeviceAuthorization } from '@pythoughts/pythinker-code-oauth';
32
import type { PythinkerHarness, Session } from '@pythoughts/pythinker-code-sdk';
43

54
import type { ColorToken, ThemeName } from '#/tui/theme';
@@ -179,7 +178,6 @@ export interface SlashCommandHost {
179178

180179
// UI
181180
showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
182-
showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle;
183181
showProgressSpinner(label: string): LoginProgressSpinnerHandle;
184182

185183
// Theme

apps/pythinker-code/src/tui/commands/info.ts

Lines changed: 3 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { release as osRelease, type as osType } from 'node:os';
21
import { join, relative } from 'node:path';
32

43
import type {
@@ -19,65 +18,20 @@ import {
1918
buildCostReportLines,
2019
buildUsageReportLines,
2120
UsagePanelComponent,
22-
type ManagedUsageReport,
2321
} from '../components/messages/usage-panel';
2422
import {
2523
FEEDBACK_ISSUE_URL,
26-
FEEDBACK_STATUS_CANCELLED,
27-
FEEDBACK_STATUS_FALLBACK,
28-
FEEDBACK_STATUS_NOT_SIGNED_IN,
29-
FEEDBACK_STATUS_SUBMITTING,
30-
FEEDBACK_STATUS_SUCCESS,
31-
FEEDBACK_TELEMETRY_EVENT,
32-
feedbackSessionLine,
33-
withFeedbackVersionPrefix,
3424
} from '../constant/feedback';
35-
import { isManagedUsageProvider } from '../constant/pythinker-tui';
3625
import { formatErrorMessage } from '../utils/event-payload';
37-
import { promptFeedbackInput } from './prompts';
3826
import type { SlashCommandHost } from './dispatch';
3927

4028
// ---------------------------------------------------------------------------
4129
// Feedback
4230
// ---------------------------------------------------------------------------
4331

4432
export async function handleFeedbackCommand(host: SlashCommandHost): Promise<void> {
45-
const fallback = (reason: string): void => {
46-
host.showStatus(reason);
47-
host.showStatus(FEEDBACK_ISSUE_URL);
48-
openUrl(FEEDBACK_ISSUE_URL);
49-
};
50-
51-
const providerKey = host.state.appState.availableModels[host.state.appState.model]?.provider;
52-
if (!isManagedUsageProvider(providerKey)) {
53-
fallback(FEEDBACK_STATUS_NOT_SIGNED_IN);
54-
return;
55-
}
56-
57-
const content = await promptFeedbackInput(host);
58-
if (content === undefined) {
59-
host.showStatus(FEEDBACK_STATUS_CANCELLED);
60-
return;
61-
}
62-
63-
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
64-
const res = await host.harness.auth.submitFeedback({
65-
content,
66-
sessionId: host.state.appState.sessionId,
67-
version: withFeedbackVersionPrefix(host.state.appState.version),
68-
os: `${osType()} ${osRelease()}`,
69-
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
70-
});
71-
72-
if (res.kind === 'ok') {
73-
spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
74-
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
75-
host.track(FEEDBACK_TELEMETRY_EVENT);
76-
return;
77-
}
78-
79-
spinner.stop({ ok: false, label: res.message });
80-
fallback(FEEDBACK_STATUS_FALLBACK);
33+
host.showStatus(FEEDBACK_ISSUE_URL);
34+
openUrl(FEEDBACK_ISSUE_URL);
8135
}
8236

8337
// ---------------------------------------------------------------------------
@@ -94,10 +48,6 @@ interface RuntimeStatusResult {
9448
readonly error?: string;
9549
}
9650

97-
interface ManagedUsageResult {
98-
readonly usage?: ManagedUsageReport;
99-
readonly error?: string;
100-
}
10151

10252
export function showCost(host: SlashCommandHost): void {
10353
const { model, modelCostRates, totalCostUsd } = host.state.appState;
@@ -112,15 +62,12 @@ export function showCost(host: SlashCommandHost): void {
11262

11363
export async function showUsage(host: SlashCommandHost): Promise<void> {
11464
const sessionUsage = await loadSessionUsageReport(host);
115-
const managedUsage = await loadManagedUsageReport(host);
11665
const reportArgs = {
11766
sessionUsage: sessionUsage.usage,
11867
sessionUsageError: sessionUsage.error,
11968
contextUsage: host.state.appState.contextUsage,
12069
contextTokens: host.state.appState.contextTokens,
12170
maxContextTokens: host.state.appState.maxContextTokens,
122-
managedUsage: managedUsage?.usage,
123-
managedUsageError: managedUsage?.error,
12471
};
12572
const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary');
12673
host.state.transcriptContainer.addChild(panel);
@@ -171,10 +118,7 @@ export async function showContextReport(
171118
}
172119

173120
export async function showStatusReport(host: SlashCommandHost): Promise<void> {
174-
const [runtimeStatus, managedUsage] = await Promise.all([
175-
loadRuntimeStatusReport(host),
176-
loadManagedUsageReport(host),
177-
]);
121+
const runtimeStatus = await loadRuntimeStatusReport(host);
178122
const appState = host.state.appState;
179123
const reportArgs = {
180124
version: appState.version,
@@ -193,8 +137,6 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> {
193137
availableModels: appState.availableModels,
194138
status: runtimeStatus.status,
195139
statusError: runtimeStatus.error,
196-
managedUsage: managedUsage?.usage,
197-
managedUsageError: managedUsage?.error,
198140
};
199141
const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status ');
200142
host.state.transcriptContainer.addChild(panel);
@@ -456,19 +398,3 @@ async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeS
456398
}
457399
}
458400

459-
async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUsageResult | undefined> {
460-
const alias = host.state.appState.model;
461-
const providerKey = host.state.appState.availableModels[alias]?.provider;
462-
if (!isManagedUsageProvider(providerKey)) return undefined;
463-
464-
let res;
465-
try {
466-
res = await host.harness.auth.getManagedUsage(providerKey);
467-
} catch (error) {
468-
return { error: formatErrorMessage(error) };
469-
}
470-
if (res.kind === 'error') {
471-
return { error: res.message };
472-
}
473-
return { usage: { summary: res.summary, limits: res.limits } };
474-
}

0 commit comments

Comments
 (0)