diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index a31d80c02..1cc9ca5de 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -26,6 +26,7 @@ import type { KimiEventConnection, KimiEventHandlers, KimiWebApi, + OAuthLoginStartResult, Page, PageRequest, PromptSubmission, @@ -1184,27 +1185,24 @@ export class DaemonKimiWebApi implements KimiWebApi { }; } - async startOAuthLogin(): Promise<{ - flowId: string; - provider: string; - verificationUri: string; - verificationUriComplete: string; - userCode: string; - expiresIn: number; - interval: number; - status: 'pending'; - expiresAt: string; - }> { + async startOAuthLogin(): Promise { const data = await this.http.post('/oauth/login', {}); + if (data.status === 'authenticated') { + return { + flowId: data.flow_id, + provider: data.provider, + status: 'authenticated', + }; + } return { flowId: data.flow_id, provider: data.provider, + status: 'pending', 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, }; } diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 504d49209..e03f37a94 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -413,18 +413,35 @@ export interface WireAuthResult { managed_provider: WireManagedProvider | null; } -export interface WireOAuthLoginStartResult { +// `POST /oauth/login` returns one of two shapes, discriminated by `status`: +// - `pending`: a real device-code flow was started; all device fields are +// populated so the client can render the device-code step and poll. +// - `authenticated`: the toolkit already had a usable token and short- +// circuited via its `ensureFresh` fast path, so no device code was +// issued; the client can skip the device-code step and treat the login +// as already complete. +interface WireOAuthLoginStartPending { flow_id: string; provider: string; + status: 'pending'; verification_uri: string; verification_uri_complete: string; user_code: string; expires_in: number; interval: number; - status: 'pending'; expires_at: string; } +interface WireOAuthLoginStartAuthenticated { + flow_id: string; + provider: string; + status: 'authenticated'; +} + +export type WireOAuthLoginStartResult = + | WireOAuthLoginStartPending + | WireOAuthLoginStartAuthenticated; + export interface WireOAuthLoginPollResult { flow_id: string; status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 08b6ae517..c86fd0723 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -734,17 +734,7 @@ export interface KimiWebApi { 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; - }>; + startOAuthLogin(): Promise; pollOAuthLogin(): Promise<{ flowId: string; status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; @@ -753,3 +743,22 @@ export interface KimiWebApi { cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>; logout(): Promise<{ loggedOut: boolean }>; } + +/** Result of `startOAuthLogin()`, mirroring the wire discriminated union. */ +export type OAuthLoginStartResult = + | { + flowId: string; + provider: string; + status: 'pending'; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + expiresAt: string; + } + | { + flowId: string; + provider: string; + status: 'authenticated'; + }; diff --git a/apps/kimi-web/src/components/dialogs/LoginDialog.vue b/apps/kimi-web/src/components/dialogs/LoginDialog.vue index 48d44a991..54cf17a2a 100644 --- a/apps/kimi-web/src/components/dialogs/LoginDialog.vue +++ b/apps/kimi-web/src/components/dialogs/LoginDialog.vue @@ -33,17 +33,25 @@ const emit = defineEmits<{ // ------------------------------------------------------------------------- const props = defineProps<{ - onStartOAuthLogin: () => Promise<{ - flowId: string; - provider: string; - verificationUri: string; - verificationUriComplete: string; - userCode: string; - expiresIn: number; - interval: number; - status: 'pending'; - expiresAt: string; - } | null>; + onStartOAuthLogin: () => Promise< + | { + flowId: string; + provider: string; + status: 'pending'; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + expiresAt: string; + } + | { + flowId: string; + provider: string; + status: 'authenticated'; + } + | null + >; onPollOAuthLogin: () => Promise<{ flowId: string; status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; @@ -107,6 +115,19 @@ async function startFlow(): Promise { return; } + // Already-authenticated fast path: the server had a usable cached token and + // did not issue a device code. Skip the device-code UI entirely and surface + // the success state — the poller is irrelevant here. + if (result.status === 'authenticated') { + stopTimers(); + step.value = 'success'; + setTimeout(() => { + emit('success'); + emit('close'); + }, 800); + return; + } + flow.value = { flowId: result.flowId, verificationUri: result.verificationUri, diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index c8409f8af..e6fbce07a 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -7,7 +7,15 @@ import { ref, type ComputedRef } from 'vue'; import { getKimiWebApi } from '../../api'; -import type { AppMessage, AppModel, AppProvider, AppSession, AppSkill, ThinkingLevel } from '../../api/types'; +import type { + AppMessage, + AppModel, + AppProvider, + AppSession, + AppSkill, + OAuthLoginStartResult, + ThinkingLevel, +} from '../../api/types'; import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage'; import { coerceThinkingForModel } from '../../lib/modelThinking'; import type { ActivityState } from '../../types'; @@ -340,17 +348,7 @@ export function useModelProviderState( } /** Start managed Kimi OAuth device flow. Returns flow data or null on error. */ - async function startOAuthLogin(): Promise<{ - flowId: string; - provider: string; - verificationUri: string; - verificationUriComplete: string; - userCode: string; - expiresIn: number; - interval: number; - status: 'pending'; - expiresAt: string; - } | null> { + async function startOAuthLogin(): Promise { try { const api = getKimiWebApi(); return await api.startOAuthLogin(); diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 7294051fd..4b53fad34 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -32,6 +32,7 @@ import { import type { OAuthFlowSnapshot, OAuthFlowStart, + OAuthFlowStartPending, OAuthFlowStatus, OAuthLoginCancelResponse, OAuthLogoutResponse, @@ -134,20 +135,28 @@ export class OAuthService extends Disposable implements IOAuthService { resolveDevice(auth); }, }); + const fastPath: Promise = loginPromise.then(async () => { + if (state.device !== undefined) return undefined; + this.log.info('oauth startLogin: toolkit resolved without device code (already authenticated)', { + provider, + }); + await this.completeAlreadyAuthenticatedLogin(state); + return { + flow_id: state.flowId, + provider: state.provider, + status: 'authenticated', + }; + }); + loginPromise.then( () => { this.log.info('oauth startLogin: toolkit.login resolved', { provider, deviceArrived: state.device !== undefined, }); - if (state.device === undefined) { - this.flows.delete(provider); - rejectDevice( - new Error('OAuth login completed without issuing a device code (already authenticated).'), - ); - return; + if (state.device !== undefined) { + this.handleSuccess(state); } - this.handleSuccess(state); }, (error) => { this.log.warn('oauth startLogin: toolkit.login rejected', { @@ -159,8 +168,16 @@ export class OAuthService extends Disposable implements IOAuthService { }, ); - this.log.info('oauth startLogin: awaiting deviceReady', { provider }); - const device = await deviceReady; + this.log.info('oauth startLogin: awaiting device flow start', { provider }); + const winner = await Promise.race([ + deviceReady.then((device) => ({ kind: 'device' as const, device })), + fastPath.then((result) => ({ kind: 'fast' as const, result })), + ]); + if (winner.kind === 'fast' && winner.result !== undefined) { + this.log.info('oauth startLogin: fast path returned authenticated', { provider }); + return winner.result; + } + const device = winner.kind === 'device' ? winner.device : await deviceReady; this.log.info('oauth startLogin: deviceReady resolved', { provider }); return this.toFlowStart(state, device); } @@ -347,23 +364,42 @@ export class OAuthService extends Disposable implements IOAuthService { private handleSuccess(state: FlowState): void { if (state.status !== 'pending') return; this.setTerminal(state, 'authenticated'); - void this.provisionProvider(state.provider, state.oauthRef); + void this.provisionProvider(state.provider, state.oauthRef).catch((error: unknown) => { + this.log.warn('oauth provider provisioning failed', { + provider: state.provider, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + private async completeAlreadyAuthenticatedLogin(state: FlowState): Promise { + if (state.status !== 'pending') return; + await this.provisionProvider(state.provider, state.oauthRef); + if (state.status !== 'pending') return; + if (state.provider === KIMI_CODE_PROVIDER_NAME) { + await this.refreshOAuthProviderModelsBestEffort(state.provider); + if (state.status !== 'pending') return; + } + this.setTerminal(state, 'authenticated'); } private async provisionProvider(provider: string, oauthRef: OAuthRef | undefined): Promise { if (oauthRef === undefined) return; const baseUrl = this.providerService.get(provider)?.baseUrl ?? kimiCodeBaseUrl(); - try { - await this.providerService.set(provider, { - type: 'kimi', - baseUrl, - apiKey: '', - oauth: oauthRef, - }); - } catch (error) { - this.log.warn('oauth provider provisioning failed', { + await this.providerService.set(provider, { + type: 'kimi', + baseUrl, + apiKey: '', + oauth: oauthRef, + }); + } + + private async refreshOAuthProviderModelsBestEffort(provider: string): Promise { + const result = await this.refreshOAuthProviderModels(); + if (result.failed.length > 0) { + this.log.warn('oauth startLogin: model refresh failed on already-authenticated fast path', { provider, - error: error instanceof Error ? error.message : String(error), + failures: result.failed, }); } } @@ -416,7 +452,7 @@ export class OAuthService extends Disposable implements IOAuthService { state.gcTimer = timer; } - private toFlowStart(state: FlowState, device: DeviceAuthorization): OAuthFlowStart { + private toFlowStart(state: FlowState, device: DeviceAuthorization): OAuthFlowStartPending { const expiresIn = device.expiresIn ?? DEFAULT_DEVICE_EXPIRES_IN_SEC; return { flow_id: state.flowId, diff --git a/packages/agent-core-v2/test/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts index 65f7d8172..b72b69654 100644 --- a/packages/agent-core-v2/test/auth/auth.test.ts +++ b/packages/agent-core-v2/test/auth/auth.test.ts @@ -70,13 +70,39 @@ describe('OAuthService', () => { }, [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' }, }; - providerSet = vi.fn().mockResolvedValue(undefined); + providerSet = vi.fn(async (name: string, config: ProviderConfig) => { + providers = { ...providers, [name]: config }; + }); models = {}; services = undefined; defaultModel = undefined; defaultThinking = undefined; - configSet = vi.fn().mockResolvedValue(undefined); - configReplace = vi.fn().mockResolvedValue(undefined); + configSet = vi.fn(async (domain: string, value: unknown) => { + if (domain === 'defaultModel') { + defaultModel = value as string | undefined; + return; + } + if (domain === 'defaultThinking') { + defaultThinking = value as boolean | undefined; + return; + } + throw new Error(`unexpected config set: ${domain}`); + }); + configReplace = vi.fn(async (domain: string, value: unknown) => { + if (domain === 'providers') { + providers = value as Record; + return; + } + if (domain === 'models') { + models = value as Record; + return; + } + if (domain === 'services') { + services = value as Record | undefined; + return; + } + throw new Error(`unexpected config replace: ${domain}`); + }); events = []; toolkit = { login: vi.fn(), @@ -135,6 +161,24 @@ describe('OAuthService', () => { return { providers, models, services, defaultModel, defaultThinking }; } + function stubManagedModelsFetch(): ReturnType { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { + id: 'kimi-k2', + context_length: 131072, + supports_reasoning: true, + display_name: 'Kimi K2', + }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + } + it('startLogin resolves a device-code flow and flips to authenticated on success', async () => { toolkit.login.mockImplementation(async (_provider, options) => { options.onDeviceCode(deviceAuth); @@ -156,8 +200,7 @@ describe('OAuthService', () => { expect.objectContaining({ oauthRef: { storage: 'file', key: 'oauth/kimi-code' } }), ); - await flush(); - expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('authenticated'); + await vi.waitFor(() => expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('authenticated')); }); it('provisions the managed provider through the provider service after login', async () => { @@ -213,11 +256,68 @@ describe('OAuthService', () => { ); }); - it('startLogin rejects when login completes without issuing a device code', async () => { + it('startLogin returns authenticated when login resolves without issuing a device code (already-authenticated fast path)', async () => { + const fetchMock = stubManagedModelsFetch(); toolkit.login.mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true }); const svc = createService(); - await expect(svc.startLogin(OAUTH_PROVIDER)).rejects.toThrow('already authenticated'); - expect(svc.getFlow(OAUTH_PROVIDER)).toBeUndefined(); + + const start = await svc.startLogin(OAUTH_PROVIDER); + expect(start).toMatchObject({ + provider: OAUTH_PROVIDER, + status: 'authenticated', + flow_id: expect.any(String), + }); + expect(providerSet).toHaveBeenCalledWith( + OAUTH_PROVIDER, + expect.objectContaining({ + type: 'kimi', + baseUrl: 'https://api.example.com', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(configSet).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2'); + }); + + it('startLogin returns authenticated when model refresh fails on the already-authenticated fast path', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network disabled in test')); + vi.stubGlobal('fetch', fetchMock); + toolkit.login.mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true }); + const svc = createService(); + + await expect(svc.startLogin(OAUTH_PROVIDER)).resolves.toMatchObject({ + provider: OAUTH_PROVIDER, + status: 'authenticated', + flow_id: expect.any(String), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(providerSet).toHaveBeenCalledWith( + OAUTH_PROVIDER, + expect.objectContaining({ + type: 'kimi', + baseUrl: 'https://api.example.com', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }), + ); + expect(configSet).not.toHaveBeenCalledWith('defaultModel', expect.any(String)); + }); + + it('keeps a device-code login authenticated when model fetch is unavailable after authorization', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network disabled in test')); + vi.stubGlobal('fetch', fetchMock); + toolkit.login.mockImplementation(async (_provider, options) => { + options.onDeviceCode(deviceAuth); + return { providerName: OAUTH_PROVIDER, ok: true }; + }); + const svc = createService(); + + await expect(svc.startLogin(OAUTH_PROVIDER)).resolves.toMatchObject({ + provider: OAUTH_PROVIDER, + status: 'pending', + }); + await vi.waitFor(() => expect(svc.getFlow(OAUTH_PROVIDER)?.status).toBe('authenticated')); + expect(fetchMock).not.toHaveBeenCalled(); + expect(configSet).not.toHaveBeenCalledWith('defaultModel', expect.any(String)); }); it('cancelLogin aborts a pending flow and marks it cancelled', async () => { diff --git a/packages/protocol/src/rest/oauth.ts b/packages/protocol/src/rest/oauth.ts index 0703db410..967890a20 100644 --- a/packages/protocol/src/rest/oauth.ts +++ b/packages/protocol/src/rest/oauth.ts @@ -22,17 +22,42 @@ export const oauthLoginStartRequestSchema = z.object({ }); export type OAuthLoginStartRequest = z.infer; -export const oauthFlowStartSchema = z.object({ +/** + * Result of `POST /v1/oauth/login`. + * + * Two shapes, discriminated by `status`: + * - `pending`: a real device-code flow was started; the `verification_*`, + * `user_code`, `expires_*`, and `interval` fields are populated so the + * client can render the device-code step and start polling. + * - `authenticated`: the toolkit already had a usable token and short- + * circuited via its `ensureFresh` fast path, so no device code was + * issued. The client can skip the device-code step and treat the login + * as already complete. + */ +export const oauthFlowStartPendingSchema = z.object({ flow_id: z.string().min(1), provider: z.string().min(1), + status: z.literal('pending'), 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 OAuthFlowStartPending = z.infer; + +export const oauthFlowStartAuthenticatedSchema = z.object({ + flow_id: z.string().min(1), + provider: z.string().min(1), + status: z.literal('authenticated'), +}); +export type OAuthFlowStartAuthenticated = z.infer; + +export const oauthFlowStartSchema = z.discriminatedUnion('status', [ + oauthFlowStartPendingSchema, + oauthFlowStartAuthenticatedSchema, +]); export type OAuthFlowStart = z.infer; export const oauthFlowSnapshotSchema = z.object({