diff --git a/.changeset/chatgpt-oauth-provider.md b/.changeset/chatgpt-oauth-provider.md new file mode 100644 index 0000000000..a3467cd348 --- /dev/null +++ b/.changeset/chatgpt-oauth-provider.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add ChatGPT Plus/Pro OAuth as a login provider. Run /login and choose OpenAI ChatGPT Plus/Pro to set it up. diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 773638485f..9532ee9e5a 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -1,14 +1,25 @@ +import { join } from 'node:path'; + import { + applyOpenAICodexConfig, applyOpenPlatformConfig, + FileTokenStorage, + fetchOpenAICodexModels, fetchOpenPlatformModels, filterModelsByPrefix, getOpenPlatformById, + loginOpenAICodexDeviceCode, + OPENAI_CODEX_MODELS, + OPENAI_CODEX_OAUTH_KEY, + OPENAI_CODEX_PROVIDER_NAME, OpenPlatformApiError, + resolveKimiTokenStorageName, type ManagedKimiCodeModelInfo, type ManagedKimiConfigShape, + type OpenAICodexModelInfo, type OpenPlatformDefinition, } from '@moonshot-ai/kimi-code-oauth'; -import { log } from '@moonshot-ai/kimi-code-sdk'; +import { log, type ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui'; @@ -19,6 +30,7 @@ import { promptLogoutProviderSelection, promptModelSelectionForOpenPlatform, promptPlatformSelection, + runModelSelector, } from './prompts'; import type { SlashCommandHost } from './dispatch'; @@ -34,12 +46,131 @@ export async function handleLoginCommand(host: SlashCommandHost): Promise await handleKimiCodeOAuthLogin(host); return; } + if (platformId === OPENAI_CODEX_PROVIDER_NAME) { + await handleOpenAICodexOAuthLogin(host); + return; + } const platform = getOpenPlatformById(platformId); if (platform === undefined) return; await handleOpenPlatformLogin(host, platform); } +async function handleOpenAICodexOAuthLogin(host: SlashCommandHost): Promise { + let spinner: LoginProgressSpinnerHandle | undefined; + const tokenStorage = new FileTokenStorage(join(host.harness.homeDir, 'credentials')); + const tokenStorageName = resolveKimiTokenStorageName({ + providerName: OPENAI_CODEX_PROVIDER_NAME, + oauthKey: OPENAI_CODEX_OAUTH_KEY, + }); + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + let openAICodexModels: readonly OpenAICodexModelInfo[] = OPENAI_CODEX_MODELS; + try { + const token = await loginOpenAICodexDeviceCode({ + signal: controller.signal, + onDeviceCode: (data) => { + spinner = host.showLoginAuthorizationPrompt( + { + userCode: data.userCode, + deviceCode: '', + verificationUri: data.verificationUri, + verificationUriComplete: data.verificationUri, + expiresIn: data.expiresIn, + interval: data.interval, + }, + { title: 'Sign in to ChatGPT' }, + ); + }, + }); + await tokenStorage.save(tokenStorageName, token); + try { + openAICodexModels = await fetchOpenAICodexModels(token.accessToken, { + signal: controller.signal, + }); + } catch (error) { + log.warn('failed to fetch OpenAI Codex models; using built-in fallback', { + providerName: OPENAI_CODEX_PROVIDER_NAME, + sessionId: host.session?.id, + error, + }); + } + spinner?.stop({ ok: true, label: 'Logged in.' }); + spinner = undefined; + } catch (error) { + const cancelled = controller.signal.aborted; + spinner?.stop({ + ok: false, + label: cancelled ? 'Login cancelled.' : 'Login failed.', + }); + spinner = undefined; + if (!cancelled) { + log.warn('login failed', { + providerName: OPENAI_CODEX_PROVIDER_NAME, + sessionId: host.session?.id, + error, + }); + host.showError(`Login failed: ${formatErrorMessage(error)}`); + } + return; + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } + + const modelDict: Record = {}; + for (const model of openAICodexModels) { + const supportEfforts = [...(model.supportEfforts ?? ['low', 'medium', 'high', 'xhigh'])]; + modelDict[`${OPENAI_CODEX_PROVIDER_NAME}/${model.id}`] = { + provider: OPENAI_CODEX_PROVIDER_NAME, + model: model.id, + maxContextSize: model.contextLength, + capabilities: model.supportsImageIn + ? ['tool_use', 'thinking', 'image_in'] + : ['tool_use', 'thinking'], + displayName: model.displayName, + supportEfforts, + defaultEffort: model.defaultEffort ?? (supportEfforts.includes('medium') ? 'medium' : supportEfforts[0]), + }; + } + const selection = await runModelSelector(host, modelDict); + if (selection === undefined) { + await tokenStorage.remove(tokenStorageName); + return; + } + + const selectedModelId = selection.alias.slice(`${OPENAI_CODEX_PROVIDER_NAME}/`.length); + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[OPENAI_CODEX_PROVIDER_NAME] !== undefined) { + await host.harness.removeProvider(OPENAI_CODEX_PROVIDER_NAME); + } + + const config = await host.harness.getConfig(); + applyOpenAICodexConfig(config as ManagedKimiConfigShape, { + selectedModelId, + thinking: selection.thinking !== 'off', + effort: + selection.thinking !== 'off' && selection.thinking !== 'on' + ? selection.thinking + : undefined, + models: openAICodexModels, + }); + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + thinking: config.thinking, + }); + + await host.authFlow.refreshConfigAfterLogin(); + host.track('login', { provider: OPENAI_CODEX_PROVIDER_NAME, method: 'oauth' }); + host.showStatus(`Setup complete: OpenAI ChatGPT Plus/Pro · ${selectedModelId}`); +} + async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise { const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); const alreadyLoggedIn = status.providers.some( @@ -222,6 +353,14 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise if (target === DEFAULT_OAUTH_PROVIDER_NAME) { await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); } else { + if (target === OPENAI_CODEX_PROVIDER_NAME) { + await new FileTokenStorage(join(host.harness.homeDir, 'credentials')).remove( + resolveKimiTokenStorageName({ + providerName: OPENAI_CODEX_PROVIDER_NAME, + oauthKey: OPENAI_CODEX_OAUTH_KEY, + }), + ); + } await host.harness.removeProvider(target); } diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7508bc06a5..03b447c3c9 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -125,7 +125,10 @@ export interface SlashCommandHost { // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; - showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; + showLoginAuthorizationPrompt( + auth: DeviceAuthorization, + options?: { readonly title?: string | undefined }, + ): LoginProgressSpinnerHandle; showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts index a332f70af6..ec9f7682ce 100644 --- a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -1,9 +1,10 @@ -import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; +import { OPENAI_CODEX_PROVIDER_NAME, OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ { value: 'kimi-code', label: 'Kimi Code (OAuth)' }, + { value: OPENAI_CODEX_PROVIDER_NAME, label: 'OpenAI ChatGPT Plus/Pro (OAuth)' }, ...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })), ]; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 62d1b2370b..fc820f6451 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2183,11 +2183,14 @@ export class KimiTUI { }; } - showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { + showLoginAuthorizationPrompt( + auth: DeviceAuthorization, + options: { readonly title?: string | undefined } = {}, + ): LoginProgressSpinnerHandle { openUrl(auth.verificationUriComplete); this.state.transcriptContainer.addChild( new DeviceCodeBoxComponent({ - title: 'Sign in to Kimi Code', + title: options.title ?? 'Sign in to Kimi Code', url: auth.verificationUriComplete, code: auth.userCode, hint: 'Press Ctrl-C to cancel', diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index de42925885..4bf0c1d198 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -35,7 +35,7 @@ Two paths when adding: - **Custom registry (api.json)**: paste a custom registry URL and Bearer token; the CLI automatically creates the `providers` / `models` entries. On later startup, providers from the same registry URL are refreshed together, so upstream provider additions, removals, and model metadata changes are synced. ::: warning -Kimi Code OAuth managed accounts logged in via `/login` do not appear in `/provider`. Use `/login` and `/logout` to manage them. +OAuth accounts logged in via `/login`, including Kimi Code and ChatGPT Plus/Pro, do not appear in `/provider`. Use `/login` and `/logout` to manage them. ::: The same operations are also available in non-interactive environments via the shell command: [`kimi provider`](../reference/kimi-command.md#kimi-provider). @@ -154,7 +154,7 @@ To route Vertex requests through a custom (e.g. proxied) endpoint, set `base_url ## OAuth and credential injection -The Kimi Code managed service uses OAuth rather than static API keys. After running `/login`, the built-in authentication toolchain automatically writes and refreshes credentials — no manual configuration is needed in `config.toml` for this. +The Kimi Code managed service and ChatGPT Plus/Pro provider use OAuth rather than static API keys. After running `/login`, the built-in authentication toolchain automatically writes and refreshes credentials — no manual configuration is needed in `config.toml` for this. ## Next steps diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index 2ff8f2a663..2eb34b685e 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -100,9 +100,10 @@ On first launch you need to configure an API source. In the interactive UI, ente /login ``` -`/login` opens a platform selector supporting two options: +`/login` opens a platform selector supporting these options: - **Kimi Code (OAuth)** — device-code flow; open the link on any device, sign in, and enter the code to authorize +- **OpenAI ChatGPT Plus/Pro (OAuth)** — sign in with a ChatGPT subscription and choose one of the bundled Codex models - **Kimi Platform API key** — enter an API key from `platform.kimi.com` or `platform.kimi.ai` To sign out, enter `/logout` to clear the current credentials. diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index b84351e9ef..159bd0bdc1 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -35,7 +35,7 @@ Kimi Code CLI 支持同时接入多家 LLM 平台——用 Kimi Code 托管服 - **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目。后续启动时,同一个 registry 地址下的供应商会一起刷新,因此上游新增、删除供应商以及模型元数据变化都会同步。 ::: warning -通过 `/login` 登录的 Kimi Code OAuth 托管账号不会在 `/provider` 里显示,请用 `/login` 和 `/logout` 管理。 +通过 `/login` 登录的 OAuth 账号(包括 Kimi Code 和 ChatGPT Plus/Pro)不会在 `/provider` 里显示,请用 `/login` 和 `/logout` 管理。 ::: 非交互环境下也可以用 shell 命令完成同样操作:[`kimi provider`](../reference/kimi-command.md#kimi-provider)。 @@ -154,7 +154,7 @@ kimi ## OAuth 与凭证注入 -Kimi Code 托管服务使用 OAuth 而非静态 API 密钥。运行 `/login` 后,内置的认证工具链会自动写入并刷新凭证,`config.toml` 里无需手动配置这部分内容。 +Kimi Code 托管服务和 ChatGPT Plus/Pro 供应商使用 OAuth 而非静态 API 密钥。运行 `/login` 后,内置的认证工具链会自动写入并刷新凭证,`config.toml` 里无需手动配置这部分内容。 ## 下一步 diff --git a/docs/zh/guides/getting-started.md b/docs/zh/guides/getting-started.md index ca8ef87fc7..4ca679988c 100644 --- a/docs/zh/guides/getting-started.md +++ b/docs/zh/guides/getting-started.md @@ -100,9 +100,10 @@ kimi -c /login ``` -`/login` 会弹出平台选择器,支持两种方式: +`/login` 会弹出平台选择器,支持以下方式: - **Kimi Code(OAuth)** — 验证码流程,在任意设备打开链接、登录并输入验证码即可授权 +- **OpenAI ChatGPT Plus/Pro(OAuth)** — 使用 ChatGPT 订阅登录,并选择内置的 Codex 模型 - **Kimi Platform API 密钥** — 输入来自 `platform.kimi.com` 或 `platform.kimi.ai` 的 API 密钥 需要退出登录时,输入 `/logout` 清除当前凭证。 diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index a982ee568e..761a5a5455 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -407,10 +407,12 @@ export class FullCompaction { : undefined; const provider = applyCompletionBudget({ provider: this.agent.config.provider, - budget: resolveCompletionBudget({ - maxOutputSize: this.agent.config.maxOutputSize ?? defaultCompactionCap, - reservedContextSize: this.agent.kimiConfig?.loopControl?.reservedContextSize, - }), + budget: this.agent.config.disableCompletionBudget + ? undefined + : resolveCompletionBudget({ + maxOutputSize: this.agent.config.maxOutputSize ?? defaultCompactionCap, + reservedContextSize: this.agent.kimiConfig?.loopControl?.reservedContextSize, + }), capability, }); const instruction = this.buildInstruction(data.instruction); diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index d4724b6fc2..3bc2acdc06 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -178,6 +178,10 @@ export class ConfigState { return this.tryResolvedProviderConfig()?.maxOutputSize; } + get disableCompletionBudget(): boolean { + return this.tryResolvedProviderConfig()?.disableCompletionBudget === true; + } + private get resolvedProviderConfig(): ResolvedRuntimeProvider | undefined { if (this._modelAlias === undefined) return undefined; return this.agent.modelProvider?.resolveProviderConfig(this._modelAlias); diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index f33f0ab59f..2957ae7032 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -307,10 +307,12 @@ export class Agent { // is applied in ConfigState.provider so compaction shares it. See get provider(). const provider = this.config.provider; const loopControl = this.kimiConfig?.loopControl; - const completionBudgetConfig = resolveCompletionBudget({ - maxOutputSize: this.config.maxOutputSize, - reservedContextSize: loopControl?.reservedContextSize, - }); + const completionBudgetConfig = this.config.disableCompletionBudget + ? undefined + : resolveCompletionBudget({ + maxOutputSize: this.config.maxOutputSize, + reservedContextSize: loopControl?.reservedContextSize, + }); return new KosongLLM({ provider, systemPrompt: this.config.systemPrompt, diff --git a/packages/agent-core/src/services/auth/managedAuth.ts b/packages/agent-core/src/services/auth/managedAuth.ts index 04ddbb1c87..0137e27e26 100644 --- a/packages/agent-core/src/services/auth/managedAuth.ts +++ b/packages/agent-core/src/services/auth/managedAuth.ts @@ -1,13 +1,20 @@ +import { join } from 'node:path'; + import { readConfigFile, writeConfigFile } from '../../config'; import type { KimiConfig, OAuthRef } from '../../config'; import type { OAuthTokenProviderResolver } from '../../session/provider-manager'; import { applyManagedKimiCodeConfig, applyManagedKimiCodeLogoutConfig, + createOpenAICodexTokenProvider, + FileTokenStorage, KIMI_CODE_PROVIDER_NAME, KimiOAuthToolkit, + OPENAI_CODEX_OAUTH_KEY, + OPENAI_CODEX_PROVIDER_NAME, resolveKimiCodeLoginAuth, resolveKimiCodeRuntimeAuth, + resolveKimiTokenStorageName, type BearerTokenProvider, type KimiOAuthLoginOptions, type ManagedKimiConfigShape, @@ -112,6 +119,15 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { providerName?: string, oauthRef?: OAuthRef | undefined, ): Promise { + if (this.isOpenAICodexAuth(providerName, oauthRef)) { + const storageName = resolveKimiTokenStorageName({ + providerName: providerName ?? OPENAI_CODEX_PROVIDER_NAME, + oauthKey: oauthRef?.key ?? OPENAI_CODEX_OAUTH_KEY, + }); + return new FileTokenStorage(join(this.options.homeDir, 'credentials')) + .load(storageName) + .then((token) => token?.accessToken); + } return this.toolkit.getCachedAccessToken( providerName, this.runtimeOAuthRef(providerName, oauthRef), @@ -122,6 +138,13 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { providerName: string, oauthRef?: OAuthRef | undefined, ): BearerTokenProvider => { + if (this.isOpenAICodexAuth(providerName, oauthRef)) { + return createOpenAICodexTokenProvider({ + storage: new FileTokenStorage(join(this.options.homeDir, 'credentials')), + providerName, + oauthRef, + }); + } return this.toolkit.tokenProvider( providerName, this.runtimeOAuthRef(providerName, oauthRef), @@ -165,6 +188,16 @@ class ServicesManagedAuthFacade implements ServicesAuthFacade { configuredOAuthRef: oauthRef ?? auth.oauthRef, }).oauthRef; } + + private isOpenAICodexAuth( + providerName: string | undefined, + oauthRef?: OAuthRef | undefined, + ): boolean { + return ( + providerName === OPENAI_CODEX_PROVIDER_NAME || + oauthRef?.key === OPENAI_CODEX_OAUTH_KEY + ); + } } export function createManagedAuthFacade( diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 14a6fb5c95..d15a0ba5fb 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,7 +1,11 @@ import type { Logger } from '#/logging/types'; import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; -import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; +import { + extractOpenAICodexAccountId, + OPENAI_CODEX_PROVIDER_NAME, + parseKimiCodeCustomHeaders, +} from '@moonshot-ai/kimi-code-oauth'; import { effectiveModelAlias, type KimiConfig, @@ -32,6 +36,8 @@ export interface ResolvedRuntimeProvider { readonly type: ProviderType; /** Model-level protocol override (`alias.protocol`); when set, takes precedence over `type` for transport selection. */ readonly protocol: ModelAlias['protocol']; + /** Disable local max-completion-token injection for providers that reject it. */ + readonly disableCompletionBudget?: boolean; } interface ProviderManagerOptions { @@ -119,12 +125,14 @@ export class ProviderManager implements ModelProvider { ); } + const disableCompletionBudget = providerName === OPENAI_CODEX_PROVIDER_NAME; + const maxOutputSize = disableCompletionBudget ? undefined : effectiveAlias.maxOutputSize; const provider = toKosongProviderConfig( providerConfig, alias.model, alias.protocol, this.options.kimiRequestHeaders, - effectiveAlias.maxOutputSize, + maxOutputSize, effectiveAlias.reasoningKey, this.options.promptCacheKey, effectiveAlias.adaptiveThinking, @@ -139,9 +147,10 @@ export class ProviderManager implements ModelProvider { alwaysThinking: (effectiveAlias.capabilities ?? []).some( (c) => c.trim().toLowerCase() === 'always_thinking', ), - maxOutputSize: effectiveAlias.maxOutputSize, + maxOutputSize, type: providerConfig.type, protocol: alias.protocol, + disableCompletionBudget, }; } @@ -192,6 +201,23 @@ export class ProviderManager implements ModelProvider { throw error; } if (apiKey.trim().length === 0) throw loginRequired(); + if (providerName === OPENAI_CODEX_PROVIDER_NAME) { + const accountId = extractOpenAICodexAccountId(apiKey); + if (accountId === undefined) { + throw new KimiError( + ErrorCodes.PROVIDER_AUTH_ERROR, + `OAuth provider "${providerName}" returned a token without a ChatGPT account id.`, + ); + } + return { + apiKey, + headers: { + 'ChatGPT-Account-Id': accountId, + 'OpenAI-Beta': 'responses=experimental', + originator: 'kimi-code', + }, + }; + } return { apiKey }; }; diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index da8206449d..dab8435c28 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -255,6 +255,41 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { expect(resolved.maxOutputSize).toBe(384000); }); + it('ignores openai-codex maxOutputSize because ChatGPT codex rejects max_output_tokens', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + 'openai-codex': { + type: 'openai_responses', + apiKey: '', + baseUrl: 'https://chatgpt.com/backend-api/codex', + oauth: { + storage: 'file', + key: 'oauth/openai-codex', + oauthHost: 'https://auth.openai.com', + }, + }, + }, + models: { + ...BASE_CONFIG.models!, + 'openai-codex/gpt-5.5': { + provider: 'openai-codex', + model: 'gpt-5.5', + maxContextSize: 353000, + maxOutputSize: 128000, + }, + }, + }, + model: 'openai-codex/gpt-5.5', + }); + + expect(resolved.maxOutputSize).toBeUndefined(); + expect(resolved.disableCompletionBudget).toBe(true); + expect(resolved.provider).not.toHaveProperty('maxOutputTokens'); + }); + it('forwards alias.maxOutputSize to the anthropic provider config as defaultMaxTokens', () => { const resolved = resolveRuntimeProvider({ config: { diff --git a/packages/node-sdk/src/auth.ts b/packages/node-sdk/src/auth.ts index 4f02747f88..621f66260b 100644 --- a/packages/node-sdk/src/auth.ts +++ b/packages/node-sdk/src/auth.ts @@ -1,3 +1,5 @@ +import { join } from 'node:path'; + import { loadRuntimeConfigSafe, readConfigFile, @@ -9,10 +11,15 @@ import { import { applyManagedKimiCodeConfig, applyManagedKimiCodeLogoutConfig, + createOpenAICodexTokenProvider, + FileTokenStorage, KIMI_CODE_PROVIDER_NAME, KimiOAuthToolkit, + OPENAI_CODEX_OAUTH_KEY, + OPENAI_CODEX_PROVIDER_NAME, resolveKimiCodeLoginAuth, resolveKimiCodeRuntimeAuth, + resolveKimiTokenStorageName, type AuthManagedUsageResult, type AuthStatus, type BearerTokenProvider, @@ -251,6 +258,15 @@ export class KimiAuthFacade { providerName?: string, oauthRef?: OAuthRef | undefined, ): Promise { + if (this.isOpenAICodexAuth(providerName, oauthRef)) { + const storageName = resolveKimiTokenStorageName({ + providerName: providerName ?? OPENAI_CODEX_PROVIDER_NAME, + oauthKey: oauthRef?.key ?? OPENAI_CODEX_OAUTH_KEY, + }); + return new FileTokenStorage(join(this.options.homeDir, 'credentials')) + .load(storageName) + .then((token) => token?.accessToken); + } return this.toolkit.getCachedAccessToken( providerName, this.runtimeOAuthRef(providerName, oauthRef), @@ -261,6 +277,22 @@ export class KimiAuthFacade { providerName: string, oauthRef?: OAuthRef | undefined, ): BearerTokenProvider => { + if (this.isOpenAICodexAuth(providerName, oauthRef)) { + const provider = createOpenAICodexTokenProvider({ + storage: new FileTokenStorage(join(this.options.homeDir, 'credentials')), + providerName, + oauthRef, + }); + return { + getAccessToken: async (options) => { + try { + return await provider.getAccessToken(options); + } catch (error) { + throw mapOAuthTokenError(error, providerName) ?? error; + } + }, + }; + } const provider = this.toolkit.tokenProvider( providerName, this.runtimeOAuthRef(providerName, oauthRef), @@ -316,4 +348,14 @@ export class KimiAuthFacade { configuredOAuthRef: oauthRef ?? auth.oauthRef, }).oauthRef; } + + private isOpenAICodexAuth( + providerName: string | undefined, + oauthRef?: OAuthRef | undefined, + ): boolean { + return ( + providerName === OPENAI_CODEX_PROVIDER_NAME || + oauthRef?.key === OPENAI_CODEX_OAUTH_KEY + ); + } } diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 3c45ba960b..0ae41f6322 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -130,6 +130,27 @@ export type { OpenPlatformDefinition, } from './open-platform'; +export { + applyOpenAICodexConfig, + createOpenAICodexTokenProvider, + extractOpenAICodexAccountId, + fetchOpenAICodexModels, + loginOpenAICodexDeviceCode, + openAICodexModelToAlias, + openAICodexOAuthRef, + OPENAI_CODEX_BASE_URL, + OPENAI_CODEX_CLIENT_ID, + OPENAI_CODEX_MODELS, + OPENAI_CODEX_OAUTH_HOST, + OPENAI_CODEX_OAUTH_KEY, + OPENAI_CODEX_PROVIDER_NAME, + refreshOpenAICodexToken, +} from './openai-codex'; +export type { + OpenAICodexDeviceCode, + OpenAICodexModelInfo, +} from './openai-codex'; + export { applyCustomRegistryEntries, applyCustomRegistryProvider, diff --git a/packages/oauth/src/openai-codex.ts b/packages/oauth/src/openai-codex.ts new file mode 100644 index 0000000000..3c2d0aac09 --- /dev/null +++ b/packages/oauth/src/openai-codex.ts @@ -0,0 +1,487 @@ +import { Buffer } from 'node:buffer'; + +import { OAuthError, OAuthUnauthorizedError } from './errors'; +import type { ManagedKimiConfigShape, ManagedKimiModelAlias, ManagedKimiOAuthRef } from './managed-kimi-code'; +import { mergeRefreshedModelAlias } from './model-alias-merge'; +import type { TokenStorage } from './storage'; +import type { TokenInfo } from './types'; +import { isRecord } from './utils'; +import { resolveKimiTokenStorageName, type BearerTokenProvider } from './toolkit'; + +export const OPENAI_CODEX_PROVIDER_NAME = 'openai-codex'; +export const OPENAI_CODEX_OAUTH_KEY = 'oauth/openai-codex'; +export const OPENAI_CODEX_OAUTH_HOST = 'https://auth.openai.com'; +export const OPENAI_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; +export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; + +const DEVICE_USER_CODE_URL = `${OPENAI_CODEX_OAUTH_HOST}/api/accounts/deviceauth/usercode`; +const DEVICE_TOKEN_URL = `${OPENAI_CODEX_OAUTH_HOST}/api/accounts/deviceauth/token`; +const DEVICE_VERIFICATION_URI = `${OPENAI_CODEX_OAUTH_HOST}/codex/device`; +const DEVICE_REDIRECT_URI = `${OPENAI_CODEX_OAUTH_HOST}/deviceauth/callback`; +const TOKEN_URL = `${OPENAI_CODEX_OAUTH_HOST}/oauth/token`; +const MODELS_URL = `${OPENAI_CODEX_BASE_URL}/models`; +const REFRESH_THRESHOLD_SECONDS = 60; +const JWT_CLAIM_PATH = 'https://api.openai.com/auth'; +const MODEL_CATALOG_CLIENT_VERSIONS = ['0.200.0', '0.145.0', '0.124.0', '0.0.0'] as const; +const OPENAI_CODEX_MODEL_FIELDS = new Set([ + 'provider', + 'model', + 'maxContextSize', + 'capabilities', + 'displayName', + 'supportEfforts', + 'defaultEffort', +]); + +export interface OpenAICodexDeviceCode { + readonly userCode: string; + readonly verificationUri: string; + readonly interval: number; + readonly expiresIn: number; +} + +interface DeviceAuthInfo { + readonly deviceAuthId: string; + readonly userCode: string; + readonly interval: number; +} + +export interface OpenAICodexModelInfo { + readonly id: string; + readonly displayName: string; + readonly contextLength: number; + readonly supportsImageIn: boolean; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; +} + +export const OPENAI_CODEX_MODELS: readonly OpenAICodexModelInfo[] = [ + { + id: 'gpt-5.4', + displayName: 'GPT-5.4', + contextLength: 272000, + supportsImageIn: true, + }, + { + id: 'gpt-5.4-mini', + displayName: 'GPT-5.4 mini', + contextLength: 272000, + supportsImageIn: true, + }, + { + id: 'gpt-5.5', + displayName: 'GPT-5.5', + contextLength: 372000, + supportsImageIn: true, + }, +]; + +function tokenFromResponse(payload: unknown): TokenInfo { + if (!isRecord(payload)) throw new OAuthError('OpenAI Codex token response must be an object.'); + const accessToken = payload['access_token']; + const refreshToken = payload['refresh_token']; + const expiresIn = Number(payload['expires_in']); + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new OAuthError('OpenAI Codex token response missing access_token.'); + } + if (typeof refreshToken !== 'string' || refreshToken.length === 0) { + throw new OAuthError('OpenAI Codex token response missing refresh_token.'); + } + if (!Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new OAuthError('OpenAI Codex token response missing or invalid expires_in.'); + } + return { + accessToken, + refreshToken, + expiresAt: Math.floor(Date.now() / 1000) + expiresIn, + expiresIn, + scope: typeof payload['scope'] === 'string' ? payload['scope'] : '', + tokenType: typeof payload['token_type'] === 'string' ? payload['token_type'] : 'Bearer', + }; +} + +async function readJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +async function readError(response: Response): Promise { + const body = await response.text().catch(() => ''); + return body.length > 0 ? body : response.statusText; +} + +async function startDeviceAuth(signal?: AbortSignal): Promise { + const response = await fetch(DEVICE_USER_CODE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: OPENAI_CODEX_CLIENT_ID }), + signal, + }); + if (!response.ok) { + throw new OAuthError( + `OpenAI Codex device authorization failed (HTTP ${response.status}): ${await readError(response)}`, + ); + } + const payload = await readJson(response); + if (!isRecord(payload)) throw new OAuthError('OpenAI Codex device authorization response must be an object.'); + const deviceAuthId = payload['device_auth_id']; + const userCode = payload['user_code']; + const interval = Number(payload['interval'] ?? 5); + if (typeof deviceAuthId !== 'string' || deviceAuthId.length === 0) { + throw new OAuthError('OpenAI Codex device authorization response missing device_auth_id.'); + } + if (typeof userCode !== 'string' || userCode.length === 0) { + throw new OAuthError('OpenAI Codex device authorization response missing user_code.'); + } + return { + deviceAuthId, + userCode, + interval: Number.isFinite(interval) && interval >= 0 ? interval : 5, + }; +} + +async function pollDeviceAuth( + device: DeviceAuthInfo, + options: { readonly signal?: AbortSignal; readonly sleep?: (ms: number) => Promise }, +): Promise<{ readonly authorizationCode: string; readonly codeVerifier: string }> { + const sleep = + options.sleep ?? + ((ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + })); + const deadline = Date.now() + 15 * 60 * 1000; + for (;;) { + if (options.signal?.aborted) throw new OAuthError('OpenAI Codex login cancelled.'); + if (Date.now() > deadline) throw new OAuthError('OpenAI Codex device authorization timed out.'); + const response = await fetch(DEVICE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_auth_id: device.deviceAuthId, + user_code: device.userCode, + }), + signal: options.signal, + }); + if (response.ok) { + const payload = await readJson(response); + if (!isRecord(payload)) throw new OAuthError('OpenAI Codex device token response must be an object.'); + const authorizationCode = payload['authorization_code']; + const codeVerifier = payload['code_verifier']; + if (typeof authorizationCode !== 'string' || authorizationCode.length === 0) { + throw new OAuthError('OpenAI Codex device token response missing authorization_code.'); + } + if (typeof codeVerifier !== 'string' || codeVerifier.length === 0) { + throw new OAuthError('OpenAI Codex device token response missing code_verifier.'); + } + return { authorizationCode, codeVerifier }; + } + if (response.status !== 403 && response.status !== 404) { + throw new OAuthError( + `OpenAI Codex device token polling failed (HTTP ${response.status}): ${await readError(response)}`, + ); + } + await sleep((device.interval + 3) * 1000); + } +} + +async function exchangeAuthorizationCode( + code: string, + codeVerifier: string, + redirectUri: string, + signal?: AbortSignal, +): Promise { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: OPENAI_CODEX_CLIENT_ID, + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + signal, + }); + if (!response.ok) { + throw new OAuthError( + `OpenAI Codex token exchange failed (HTTP ${response.status}): ${await readError(response)}`, + ); + } + return tokenFromResponse(await readJson(response)); +} + +export async function loginOpenAICodexDeviceCode(options: { + readonly signal?: AbortSignal; + readonly sleep?: (ms: number) => Promise; + readonly onDeviceCode?: (info: OpenAICodexDeviceCode) => void; +} = {}): Promise { + const device = await startDeviceAuth(options.signal); + options.onDeviceCode?.({ + userCode: device.userCode, + verificationUri: DEVICE_VERIFICATION_URI, + interval: device.interval, + expiresIn: 15 * 60, + }); + const token = await pollDeviceAuth(device, options); + return exchangeAuthorizationCode( + token.authorizationCode, + token.codeVerifier, + DEVICE_REDIRECT_URI, + options.signal, + ); +} + +export async function refreshOpenAICodexToken(refreshToken: string): Promise { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: OPENAI_CODEX_CLIENT_ID, + refresh_token: refreshToken, + }), + }); + if (response.status === 401 || response.status === 403) { + throw new OAuthUnauthorizedError('OpenAI Codex OAuth credentials were rejected.'); + } + if (!response.ok) { + throw new OAuthError( + `OpenAI Codex token refresh failed (HTTP ${response.status}): ${await readError(response)}`, + ); + } + return tokenFromResponse(await readJson(response)); +} + +export function extractOpenAICodexAccountId(accessToken: string): string | undefined { + const parts = accessToken.split('.'); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse(Buffer.from(parts[1] ?? '', 'base64url').toString('utf-8')) as unknown; + if (!isRecord(payload)) return undefined; + const auth = payload[JWT_CLAIM_PATH]; + if (isRecord(auth) && typeof auth['chatgpt_account_id'] === 'string') { + return auth['chatgpt_account_id']; + } + return undefined; + } catch { + return undefined; + } +} + +function readModelNumber(model: Record, key: string): number | undefined { + const value = model[key]; + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +function parseReasoningEfforts(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const efforts = value.flatMap((item) => { + if (!isRecord(item)) return []; + const effort = item['effort']; + return typeof effort === 'string' && effort.length > 0 ? [effort] : []; + }); + return efforts.length > 0 ? efforts : undefined; +} + +function parseOpenAICodexModel(value: unknown): OpenAICodexModelInfo | undefined { + if (!isRecord(value)) return undefined; + const id = value['slug']; + if (typeof id !== 'string' || id.length === 0) return undefined; + if (value['visibility'] === 'hide') return undefined; + if (value['supported_in_api'] === false) return undefined; + if (value['upgrade'] !== null && value['upgrade'] !== undefined) return undefined; + const contextLength = + readModelNumber(value, 'context_window') ?? readModelNumber(value, 'max_context_window'); + if (contextLength === undefined) return undefined; + + const inputModalities = value['input_modalities']; + const supportsImageIn = + Array.isArray(inputModalities) && inputModalities.some((item) => item === 'image'); + const displayName = typeof value['display_name'] === 'string' && value['display_name'].length > 0 + ? value['display_name'] + : id; + const defaultEffort = value['default_reasoning_level']; + return { + id, + displayName, + contextLength, + supportsImageIn, + supportEfforts: parseReasoningEfforts(value['supported_reasoning_levels']), + defaultEffort: typeof defaultEffort === 'string' && defaultEffort.length > 0 + ? defaultEffort + : undefined, + }; +} + +async function fetchOpenAICodexModelsForVersion( + accessToken: string, + clientVersion: string, + signal?: AbortSignal, +): Promise { + const accountId = extractOpenAICodexAccountId(accessToken); + if (accountId === undefined) { + throw new OAuthError('OpenAI Codex OAuth token missing ChatGPT account id.'); + } + const url = new URL(MODELS_URL); + url.searchParams.set('client_version', clientVersion); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'ChatGPT-Account-Id': accountId, + originator: 'kimi-code', + }, + signal, + }); + if (response.status === 401 || response.status === 403) { + throw new OAuthUnauthorizedError('OpenAI Codex OAuth credentials were rejected.'); + } + if (!response.ok) { + throw new OAuthError( + `OpenAI Codex models endpoint failed (HTTP ${response.status}): ${await readError(response)}`, + ); + } + const payload = await readJson(response); + if (!isRecord(payload) || !Array.isArray(payload['models'])) { + throw new OAuthError('OpenAI Codex models response must include a models array.'); + } + return payload['models'].flatMap((item) => { + const parsed = parseOpenAICodexModel(item); + return parsed === undefined ? [] : [parsed]; + }); +} + +export async function fetchOpenAICodexModels( + accessToken: string, + options: { + readonly signal?: AbortSignal; + readonly clientVersions?: readonly string[]; + } = {}, +): Promise { + const versions = options.clientVersions ?? MODEL_CATALOG_CLIENT_VERSIONS; + let lastError: unknown; + for (const clientVersion of versions) { + try { + const models = await fetchOpenAICodexModelsForVersion( + accessToken, + clientVersion, + options.signal, + ); + if (models.length > 0) return models; + } catch (error) { + lastError = error; + } + } + if (lastError instanceof Error) throw lastError; + throw new OAuthError('OpenAI Codex models endpoint returned no available models.'); +} + +export function createOpenAICodexTokenProvider(options: { + readonly storage: TokenStorage; + readonly providerName?: string | undefined; + readonly oauthRef?: Pick | undefined; + readonly now?: () => number; +}): BearerTokenProvider { + const storageName = resolveKimiTokenStorageName({ + providerName: options.providerName ?? OPENAI_CODEX_PROVIDER_NAME, + oauthKey: options.oauthRef?.key ?? OPENAI_CODEX_OAUTH_KEY, + }); + const now = options.now ?? (() => Math.floor(Date.now() / 1000)); + return { + getAccessToken: async (requestOptions) => { + const current = await options.storage.load(storageName); + if (current === undefined || current.refreshToken.length === 0) { + throw new OAuthUnauthorizedError('OpenAI Codex OAuth login is required.'); + } + if ( + requestOptions?.force !== true && + current.accessToken.length > 0 && + current.expiresAt - now() > REFRESH_THRESHOLD_SECONDS + ) { + return current.accessToken; + } + const refreshed = await refreshOpenAICodexToken(current.refreshToken); + await options.storage.save(storageName, refreshed); + return refreshed.accessToken; + }, + }; +} + +function modelCapabilities(model: OpenAICodexModelInfo): string[] { + return model.supportsImageIn + ? ['tool_use', 'thinking', 'image_in'] + : ['tool_use', 'thinking']; +} + +export function openAICodexModelToAlias(model: OpenAICodexModelInfo): ManagedKimiModelAlias { + const supportEfforts = model.supportEfforts ?? ['low', 'medium', 'high', 'xhigh']; + return { + provider: OPENAI_CODEX_PROVIDER_NAME, + model: model.id, + maxContextSize: model.contextLength, + capabilities: modelCapabilities(model), + displayName: model.displayName, + supportEfforts, + defaultEffort: model.defaultEffort ?? (supportEfforts.includes('medium') ? 'medium' : supportEfforts[0]), + }; +} + +export function openAICodexOAuthRef(): ManagedKimiOAuthRef { + return { + storage: 'file', + key: OPENAI_CODEX_OAUTH_KEY, + oauthHost: OPENAI_CODEX_OAUTH_HOST, + }; +} + +export function applyOpenAICodexConfig( + config: ManagedKimiConfigShape, + options: { + readonly selectedModelId?: string | undefined; + readonly thinking?: boolean | undefined; + readonly effort?: string | undefined; + readonly models?: readonly OpenAICodexModelInfo[]; + } = {}, +): { readonly defaultModel: string; readonly defaultThinking: boolean } { + config.providers[OPENAI_CODEX_PROVIDER_NAME] = { + type: 'openai_responses', + baseUrl: OPENAI_CODEX_BASE_URL, + apiKey: '', + oauth: openAICodexOAuthRef(), + }; + + const models = config.models ?? {}; + const upstreamModels = options.models ?? OPENAI_CODEX_MODELS; + const upstreamKeys = new Set(upstreamModels.map((model) => `${OPENAI_CODEX_PROVIDER_NAME}/${model.id}`)); + for (const [key, alias] of Object.entries(models)) { + if (isRecord(alias) && alias['provider'] === OPENAI_CODEX_PROVIDER_NAME && !upstreamKeys.has(key)) { + delete models[key]; + } + } + for (const model of upstreamModels) { + const key = `${OPENAI_CODEX_PROVIDER_NAME}/${model.id}`; + const existing = isRecord(models[key]) ? models[key] : {}; + models[key] = mergeRefreshedModelAlias( + existing, + openAICodexModelToAlias(model), + OPENAI_CODEX_MODEL_FIELDS, + ); + } + config.models = models; + + const selectedModelId = options.selectedModelId ?? 'gpt-5.4'; + const defaultModel = `${OPENAI_CODEX_PROVIDER_NAME}/${selectedModelId}`; + config.defaultModel = defaultModel; + const defaultThinking = options.thinking ?? true; + config.thinking = { + ...config.thinking, + enabled: defaultThinking, + effort: options.effort, + }; + return { defaultModel, defaultThinking }; +} diff --git a/packages/oauth/test/openai-codex.test.ts b/packages/oauth/test/openai-codex.test.ts new file mode 100644 index 0000000000..954e542899 --- /dev/null +++ b/packages/oauth/test/openai-codex.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + applyOpenAICodexConfig, + createOpenAICodexTokenProvider, + extractOpenAICodexAccountId, + fetchOpenAICodexModels, + OPENAI_CODEX_BASE_URL, + OPENAI_CODEX_OAUTH_KEY, + OPENAI_CODEX_PROVIDER_NAME, + resolveKimiTokenStorageName, + 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()]; + } +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function jwt(payload: Record): string { + const encoded = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url'); + return `header.${encoded}.signature`; +} + +function token(input: Partial = {}): TokenInfo { + return { + accessToken: input.accessToken ?? 'access-token', + refreshToken: input.refreshToken ?? 'refresh-token', + expiresAt: input.expiresAt ?? 10_000, + scope: input.scope ?? '', + tokenType: input.tokenType ?? 'Bearer', + expiresIn: input.expiresIn ?? 3600, + }; +} + +describe('extractOpenAICodexAccountId', () => { + it('reads the ChatGPT account id from OpenAI OAuth JWT claims', () => { + expect( + extractOpenAICodexAccountId( + jwt({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'account-123', + }, + }), + ), + ).toBe('account-123'); + }); + + it('returns undefined for malformed or missing claims', () => { + expect(extractOpenAICodexAccountId('not-a-jwt')).toBeUndefined(); + expect(extractOpenAICodexAccountId(jwt({}))).toBeUndefined(); + }); +}); + +describe('applyOpenAICodexConfig', () => { + it('provisions the OpenAI Codex Responses provider and bundled model aliases', () => { + const config: ManagedKimiConfigShape = { + providers: {}, + models: { + 'openai-codex/old-model': { + provider: OPENAI_CODEX_PROVIDER_NAME, + model: 'old-model', + }, + }, + }; + + const result = applyOpenAICodexConfig(config, { + selectedModelId: 'gpt-5.4-mini', + thinking: true, + effort: 'high', + }); + + expect(result.defaultModel).toBe('openai-codex/gpt-5.4-mini'); + expect(config.providers[OPENAI_CODEX_PROVIDER_NAME]).toMatchObject({ + type: 'openai_responses', + baseUrl: OPENAI_CODEX_BASE_URL, + apiKey: '', + oauth: { + storage: 'file', + key: OPENAI_CODEX_OAUTH_KEY, + oauthHost: 'https://auth.openai.com', + }, + }); + expect(config.models?.['openai-codex/old-model']).toBeUndefined(); + expect(config.models?.['openai-codex/gpt-5.4-mini']).toMatchObject({ + provider: OPENAI_CODEX_PROVIDER_NAME, + model: 'gpt-5.4-mini', + capabilities: ['tool_use', 'thinking', 'image_in'], + supportEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultEffort: 'medium', + }); + expect(config.models?.['openai-codex/gpt-5.4-mini']?.['maxOutputSize']).toBeUndefined(); + expect(config.defaultModel).toBe('openai-codex/gpt-5.4-mini'); + expect(config.thinking).toMatchObject({ enabled: true, effort: 'high' }); + }); + + it('provisions model aliases from a fetched OpenAI Codex model catalog', () => { + const config: ManagedKimiConfigShape = { providers: {} }; + + applyOpenAICodexConfig(config, { + selectedModelId: 'gpt-dynamic', + models: [ + { + id: 'gpt-dynamic', + displayName: 'GPT Dynamic', + contextLength: 353000, + supportsImageIn: true, + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + ], + }); + + expect(config.models?.['openai-codex/gpt-dynamic']).toMatchObject({ + provider: OPENAI_CODEX_PROVIDER_NAME, + model: 'gpt-dynamic', + maxContextSize: 353000, + capabilities: ['tool_use', 'thinking', 'image_in'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }); + }); +}); + +describe('fetchOpenAICodexModels', () => { + it('fetches and parses listed models from the ChatGPT Codex catalog', async () => { + const accessToken = jwt({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'account-123', + }, + }); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + models: [ + { + slug: 'gpt-dynamic', + display_name: 'GPT Dynamic', + context_window: 353000, + max_context_window: 353000, + input_modalities: ['text', 'image'], + supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }], + default_reasoning_level: 'high', + visibility: 'list', + supported_in_api: true, + upgrade: null, + }, + { + slug: 'hidden-model', + display_name: 'Hidden', + context_window: 1, + visibility: 'hide', + supported_in_api: true, + upgrade: null, + }, + { + slug: 'upgrade-model', + display_name: 'Upgrade', + context_window: 1, + visibility: 'list', + supported_in_api: true, + upgrade: { reason: 'upgrade' }, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const models = await fetchOpenAICodexModels(accessToken, { + clientVersions: ['0.200.0'], + }); + + expect(models).toEqual([ + { + id: 'gpt-dynamic', + displayName: 'GPT Dynamic', + contextLength: 353000, + supportsImageIn: true, + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + ]); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + expect(String(url)).toContain('/backend-api/codex/models?client_version=0.200.0'); + expect(init.headers).toMatchObject({ + Authorization: `Bearer ${accessToken}`, + 'ChatGPT-Account-Id': 'account-123', + }); + }); + + it('tries the next catalog client version when a response has no listed models', async () => { + const accessToken = jwt({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'account-123', + }, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ models: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + models: [ + { + slug: 'gpt-fallback', + display_name: 'GPT Fallback', + context_window: 128000, + visibility: 'list', + supported_in_api: true, + upgrade: null, + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const models = await fetchOpenAICodexModels(accessToken, { + clientVersions: ['0.1.0', '0.2.0'], + }); + + expect(models.map((model) => model.id)).toEqual(['gpt-fallback']); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('createOpenAICodexTokenProvider', () => { + it('returns an unexpired cached token from provider-specific storage', async () => { + const storage = new MemoryTokenStorage(); + const storageName = resolveKimiTokenStorageName({ + providerName: OPENAI_CODEX_PROVIDER_NAME, + oauthKey: OPENAI_CODEX_OAUTH_KEY, + }); + storage.tokens.set(storageName, token({ accessToken: 'cached-access', expiresAt: 500 })); + + const provider = createOpenAICodexTokenProvider({ + storage, + providerName: OPENAI_CODEX_PROVIDER_NAME, + now: () => 100, + }); + + await expect(provider.getAccessToken()).resolves.toBe('cached-access'); + }); + + it('refreshes expired tokens and persists the replacement', async () => { + const storage = new MemoryTokenStorage(); + const storageName = resolveKimiTokenStorageName({ + providerName: OPENAI_CODEX_PROVIDER_NAME, + oauthKey: OPENAI_CODEX_OAUTH_KEY, + }); + storage.tokens.set(storageName, token({ accessToken: 'old-access', expiresAt: 101 })); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 3600, + scope: '', + token_type: 'Bearer', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const provider = createOpenAICodexTokenProvider({ + storage, + providerName: OPENAI_CODEX_PROVIDER_NAME, + now: () => 100, + }); + + await expect(provider.getAccessToken()).resolves.toBe('new-access'); + expect(storage.tokens.get(storageName)?.refreshToken).toBe('new-refresh'); + }); +});