* feat(provider): first-class Moonshot (Kimi) direct-API support Moonshot's direct API (api.moonshot.ai/v1) is OpenAI-compatible and works today via the generic OpenAI shim, including the reasoning_content channel that Kimi returns alongside the user-visible content. But the UX was rough: unknown context window triggered the conservative 128k fallback + a warning, and the provider displayed as "Local OpenAI-compatible". Makes Moonshot a recognized provider: - src/utils/model/openaiContextWindows.ts: add the Kimi K2 family and moonshot-v1-* variants to both the context-window and max-output tables. Values from Moonshot's model card — K2.6 and K2-thinking are 256K, K2/K2-instruct are 128K, moonshot-v1 sizes are embedded in the model id. - src/utils/providerDiscovery.ts: recognize the api.moonshot.ai hostname and label it "Moonshot (Kimi)" in the startup banner and provider UI. Users can now launch with: CLAUDE_CODE_USE_OPENAI=1 \ OPENAI_BASE_URL=https://api.moonshot.ai/v1 \ OPENAI_API_KEY=sk-... \ OPENAI_MODEL=kimi-k2.6 \ openclaude and get accurate compaction + correct labeling + correct max_tokens out of the box. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * fix(openai-shim): Moonshot API compatibility — max_tokens + strip store Moonshot's direct API (api.moonshot.ai and api.moonshot.cn) uses the classic OpenAI `max_tokens` parameter, not the newer `max_completion_tokens` that the shim defaults to. It also hasn't published support for `store` and may reject it on strict-parse — same class of error as Gemini's "Unknown name 'store': Cannot find field" 400. - Adds isMoonshotBaseUrl() that recognizes both .ai and .cn hosts. - Converts max_completion_tokens → max_tokens for Moonshot requests (alongside GitHub / Mistral / local providers). - Strips body.store for Moonshot requests (alongside Mistral / Gemini). Two shim tests cover both the .ai and .cn hostnames. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * fix: null-safe access on getCachedMCConfig() in external builds External builds stub src/services/compact/cachedMicrocompact.ts so getCachedMCConfig() returns null, but two call sites still dereferenced config.supportedModels directly. The ?. operator was in the wrong place (config.supportedModels? instead of config?.supportedModels), so the null config threw "Cannot read properties of null (reading 'supportedModels')" on every request. Reproduces with any external-build provider (notably Kimi/Moonshot just enabled in the sibling commits, but equally DeepSeek, Mistral, Groq, Ollama, etc.): ❯ hey ⏺ Cannot read properties of null (reading 'supportedModels') - prompts.ts: early-return from getFunctionResultClearingSection() when config is null, before touching .supportedModels. - claude.ts: guard the debug-log jsonStringify with ?. so the log line never throws. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * fix(startup): show "Moonshot (Kimi)" on the startup banner The startup-screen provider detector had regex branches for OpenRouter, DeepSeek, Groq, Together, Azure, etc., but nothing for Moonshot. Remote Moonshot sessions fell through to the generic "OpenAI" label — getLocalOpenAICompatibleProviderLabel() only runs for local URLs, and api.moonshot.ai / api.moonshot.cn are not local. Adds a Moonshot branch matching /moonshot/ in the base URL OR /kimi/ in the model id. Now launches with: OPENAI_BASE_URL=https://api.moonshot.ai/v1 OPENAI_MODEL=kimi-k2.6 display the Provider row as "Moonshot (Kimi)" instead of "OpenAI". Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * refactor(provider): sort preset picker alphabetically; Custom at end The /provider preset picker was in ad-hoc order (Anthropic, Ollama, OpenAI, then a jumble of third-party / local / codex / Alibaba / custom / nvidia / minimax). Hard to scan when you know the provider name you want. Sorts the list alphabetically by label A→Z. Pins "Custom" to the end — it's the catch-all / escape hatch so it's scanned last, not shuffled into the alphabetical run where a user looking for a named provider might grab it by mistake. First-run-only "Skip for now" stays at the very bottom, after Custom. Test churn: - ProviderManager.test.tsx: four tests hardcoded press counts (1 or 3 'j' presses) that broke when targets moved. Replaces them with a navigateToPreset(stdin, label) helper driven from a declared PRESET_ORDER array, so future list edits only update the array. - ConsoleOAuthFlow.test.tsx: the 13-row test frame only renders the first ~13 providers. "Ollama", "OpenAI", "LM Studio" sentinels moved below the fold; swap them for alphabetically-early providers still visible in-frame ("Azure OpenAI", "DeepSeek", "Google Gemini"). Test intent (picker opened with providers listed) is preserved. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> --------- Co-authored-by: OpenClaude <openclaude@gitlawb.com>
965 lines
27 KiB
TypeScript
965 lines
27 KiB
TypeScript
import { PassThrough } from 'node:stream'
|
|
|
|
import { afterEach, expect, mock, test } from 'bun:test'
|
|
import React from 'react'
|
|
import stripAnsi from 'strip-ansi'
|
|
|
|
import { createRoot } from '../ink.js'
|
|
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
|
|
import { AppStateProvider } from '../state/AppState.js'
|
|
|
|
const SYNC_START = '\x1B[?2026h'
|
|
const SYNC_END = '\x1B[?2026l'
|
|
|
|
const ORIGINAL_ENV = {
|
|
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
|
|
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
|
GH_TOKEN: process.env.GH_TOKEN,
|
|
}
|
|
|
|
function extractLastFrame(output: string): string {
|
|
let lastFrame: string | null = null
|
|
let cursor = 0
|
|
|
|
while (cursor < output.length) {
|
|
const start = output.indexOf(SYNC_START, cursor)
|
|
if (start === -1) {
|
|
break
|
|
}
|
|
|
|
const contentStart = start + SYNC_START.length
|
|
const end = output.indexOf(SYNC_END, contentStart)
|
|
if (end === -1) {
|
|
break
|
|
}
|
|
|
|
const frame = output.slice(contentStart, end)
|
|
if (frame.trim().length > 0) {
|
|
lastFrame = frame
|
|
}
|
|
cursor = end + SYNC_END.length
|
|
}
|
|
|
|
return lastFrame ?? output
|
|
}
|
|
|
|
function createTestStreams(): {
|
|
stdout: PassThrough
|
|
stdin: PassThrough & {
|
|
isTTY: boolean
|
|
setRawMode: (mode: boolean) => void
|
|
ref: () => void
|
|
unref: () => void
|
|
}
|
|
getOutput: () => string
|
|
} {
|
|
let output = ''
|
|
const stdout = new PassThrough()
|
|
const stdin = new PassThrough() as PassThrough & {
|
|
isTTY: boolean
|
|
setRawMode: (mode: boolean) => void
|
|
ref: () => void
|
|
unref: () => void
|
|
}
|
|
|
|
stdin.isTTY = true
|
|
stdin.setRawMode = () => {}
|
|
stdin.ref = () => {}
|
|
stdin.unref = () => {}
|
|
;(stdout as unknown as { columns: number }).columns = 120
|
|
stdout.on('data', chunk => {
|
|
output += chunk.toString()
|
|
})
|
|
|
|
return {
|
|
stdout,
|
|
stdin,
|
|
getOutput: () => output,
|
|
}
|
|
}
|
|
|
|
async function waitForCondition(
|
|
predicate: () => boolean,
|
|
options?: { timeoutMs?: number; intervalMs?: number },
|
|
): Promise<void> {
|
|
const timeoutMs = options?.timeoutMs ?? 2000
|
|
const intervalMs = options?.intervalMs ?? 10
|
|
const startedAt = Date.now()
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (predicate()) {
|
|
return
|
|
}
|
|
await Bun.sleep(intervalMs)
|
|
}
|
|
|
|
throw new Error('Timed out waiting for ProviderManager test condition')
|
|
}
|
|
|
|
// Provider list is sorted alphabetically by label in the preset picker, so
|
|
// reaching a given provider takes more keypresses than it used to. Keep the
|
|
// target-by-label indirection here so these tests survive future list edits
|
|
// without further churn.
|
|
//
|
|
// Order matches ProviderManager.renderPresetSelection() when
|
|
// canUseCodexOAuth === true (default in mocked tests).
|
|
const PRESET_ORDER = [
|
|
'Alibaba Coding Plan',
|
|
'Alibaba Coding Plan (China)',
|
|
'Anthropic',
|
|
'Azure OpenAI',
|
|
'Codex OAuth',
|
|
'DeepSeek',
|
|
'Google Gemini',
|
|
'Groq',
|
|
'LM Studio',
|
|
'MiniMax',
|
|
'Mistral',
|
|
'Moonshot AI',
|
|
'NVIDIA NIM',
|
|
'Ollama',
|
|
'OpenAI',
|
|
'OpenRouter',
|
|
'Together AI',
|
|
'Custom',
|
|
] as const
|
|
|
|
async function navigateToPreset(
|
|
stdin: { write: (data: string) => void },
|
|
label: (typeof PRESET_ORDER)[number],
|
|
): Promise<void> {
|
|
const index = PRESET_ORDER.indexOf(label)
|
|
if (index < 0) throw new Error(`Unknown preset label: ${label}`)
|
|
for (let i = 0; i < index; i++) {
|
|
stdin.write('j')
|
|
await Bun.sleep(25)
|
|
}
|
|
}
|
|
|
|
function createDeferred<T>(): {
|
|
promise: Promise<T>
|
|
resolve: (value: T) => void
|
|
} {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>(r => {
|
|
resolve = r
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function mockProviderProfilesModule(options?: {
|
|
addProviderProfile?: (...args: unknown[]) => unknown
|
|
getProviderProfiles?: () => unknown[]
|
|
updateProviderProfile?: (...args: unknown[]) => unknown
|
|
setActiveProviderProfile?: (...args: unknown[]) => unknown
|
|
}): void {
|
|
mock.module('../utils/providerProfiles.js', () => ({
|
|
addProviderProfile: options?.addProviderProfile ?? (() => null),
|
|
applyActiveProviderProfileFromConfig: () => {},
|
|
deleteProviderProfile: () => ({ removed: false, activeProfileId: null }),
|
|
getActiveProviderProfile: () => null,
|
|
getProviderPresetDefaults: (preset: string) =>
|
|
preset === 'ollama'
|
|
? {
|
|
provider: 'openai',
|
|
name: 'Ollama',
|
|
baseUrl: 'http://localhost:11434/v1',
|
|
model: 'llama3.1:8b',
|
|
apiKey: '',
|
|
}
|
|
: {
|
|
provider: 'openai',
|
|
name: 'Mock provider',
|
|
baseUrl: 'http://localhost:11434/v1',
|
|
model: 'mock-model',
|
|
apiKey: '',
|
|
},
|
|
getProviderProfiles: options?.getProviderProfiles ?? (() => []),
|
|
setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
|
|
updateProviderProfile: options?.updateProviderProfile ?? (() => null),
|
|
}))
|
|
}
|
|
|
|
function mockProviderManagerDependencies(
|
|
githubSyncRead: () => string | undefined,
|
|
githubAsyncRead: () => Promise<string | undefined>,
|
|
options?: {
|
|
addProviderProfile?: (...args: unknown[]) => unknown
|
|
applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
|
|
clearCodexCredentials?: () => { success: boolean; warning?: string }
|
|
getProviderProfiles?: () => unknown[]
|
|
probeOllamaGenerationReadiness?: () => Promise<{
|
|
state: 'ready' | 'unreachable' | 'no_models' | 'generation_failed'
|
|
models: Array<
|
|
{
|
|
name: string
|
|
sizeBytes?: number | null
|
|
family?: string | null
|
|
families?: string[]
|
|
parameterSize?: string | null
|
|
quantizationLevel?: string | null
|
|
}
|
|
>
|
|
probeModel?: string
|
|
detail?: string
|
|
}>
|
|
codexSyncRead?: () => unknown
|
|
codexAsyncRead?: () => Promise<unknown>
|
|
updateProviderProfile?: (...args: unknown[]) => unknown
|
|
setActiveProviderProfile?: (...args: unknown[]) => unknown
|
|
useCodexOAuthFlow?: (options: {
|
|
onAuthenticated: (tokens: {
|
|
accessToken: string
|
|
refreshToken: string
|
|
accountId?: string
|
|
idToken?: string
|
|
apiKey?: string
|
|
}, persistCredentials: (options?: { profileId?: string }) => void) =>
|
|
void | Promise<void>
|
|
}) => {
|
|
state: 'starting' | 'waiting' | 'error'
|
|
authUrl?: string
|
|
browserOpened?: boolean | null
|
|
message?: string
|
|
}
|
|
},
|
|
): void {
|
|
mockProviderProfilesModule({
|
|
addProviderProfile: options?.addProviderProfile,
|
|
getProviderProfiles: options?.getProviderProfiles,
|
|
updateProviderProfile: options?.updateProviderProfile,
|
|
setActiveProviderProfile: options?.setActiveProviderProfile,
|
|
})
|
|
|
|
mock.module('../utils/providerDiscovery.js', () => ({
|
|
probeOllamaGenerationReadiness:
|
|
options?.probeOllamaGenerationReadiness ??
|
|
(async () => ({
|
|
state: 'unreachable' as const,
|
|
models: [],
|
|
})),
|
|
}))
|
|
|
|
mock.module('../utils/githubModelsCredentials.js', () => ({
|
|
clearGithubModelsToken: () => ({ success: true }),
|
|
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
|
|
hydrateGithubModelsTokenFromSecureStorage: () => {},
|
|
readGithubModelsToken: githubSyncRead,
|
|
readGithubModelsTokenAsync: githubAsyncRead,
|
|
}))
|
|
|
|
mock.module('../utils/codexCredentials.js', () => ({
|
|
attachCodexProfileIdToStoredCredentials: () => ({ success: true }),
|
|
clearCodexCredentials:
|
|
options?.clearCodexCredentials ?? (() => ({ success: true })),
|
|
readCodexCredentials:
|
|
options?.codexSyncRead ?? (() => undefined),
|
|
readCodexCredentialsAsync:
|
|
options?.codexAsyncRead ?? (async () => undefined),
|
|
}))
|
|
|
|
mock.module('../utils/providerProfile.js', () => ({
|
|
applySavedProfileToCurrentSession:
|
|
options?.applySavedProfileToCurrentSession ?? (async () => null),
|
|
buildCodexOAuthProfileEnv: (tokens: {
|
|
accessToken: string
|
|
accountId?: string
|
|
idToken?: string
|
|
}) => {
|
|
const accountId =
|
|
tokens.accountId ??
|
|
(tokens.idToken ? 'acct_from_id_token' : undefined) ??
|
|
(tokens.accessToken ? 'acct_from_access_token' : undefined)
|
|
|
|
if (!accountId) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
|
OPENAI_MODEL: 'codexplan',
|
|
CHATGPT_ACCOUNT_ID: accountId,
|
|
CODEX_CREDENTIAL_SOURCE: 'oauth' as const,
|
|
}
|
|
},
|
|
clearPersistedCodexOAuthProfile: () => null,
|
|
createProfileFile: (profile: string, env: Record<string, unknown>) => ({
|
|
profile,
|
|
env,
|
|
createdAt: '2026-04-10T00:00:00.000Z',
|
|
}),
|
|
}))
|
|
|
|
mock.module('../utils/settings/settings.js', () => ({
|
|
updateSettingsForSource: () => ({ error: null }),
|
|
}))
|
|
|
|
mock.module('./useCodexOAuthFlow.js', () => ({
|
|
useCodexOAuthFlow:
|
|
options?.useCodexOAuthFlow ??
|
|
(() => ({
|
|
state: 'waiting' as const,
|
|
authUrl: 'https://chatgpt.com/codex',
|
|
browserOpened: true,
|
|
})),
|
|
}))
|
|
}
|
|
|
|
async function waitForFrameOutput(
|
|
getOutput: () => string,
|
|
predicate: (output: string) => boolean,
|
|
timeoutMs = 2500,
|
|
): Promise<string> {
|
|
let output = ''
|
|
|
|
await waitForCondition(() => {
|
|
output = stripAnsi(extractLastFrame(getOutput()))
|
|
return predicate(output)
|
|
}, { timeoutMs })
|
|
|
|
return output
|
|
}
|
|
|
|
async function mountProviderManager(
|
|
ProviderManager: React.ComponentType<{
|
|
mode: 'first-run' | 'manage'
|
|
onDone: (result?: unknown) => void
|
|
}>,
|
|
options?: {
|
|
mode?: 'first-run' | 'manage'
|
|
onDone?: (result?: unknown) => void
|
|
},
|
|
): Promise<{
|
|
stdin: PassThrough
|
|
getOutput: () => string
|
|
dispose: () => Promise<void>
|
|
}> {
|
|
const { stdout, stdin, getOutput } = createTestStreams()
|
|
const root = await createRoot({
|
|
stdout: stdout as unknown as NodeJS.WriteStream,
|
|
stdin: stdin as unknown as NodeJS.ReadStream,
|
|
patchConsole: false,
|
|
})
|
|
|
|
root.render(
|
|
<AppStateProvider>
|
|
<KeybindingSetup>
|
|
<ProviderManager
|
|
mode={options?.mode ?? 'manage'}
|
|
onDone={options?.onDone ?? (() => {})}
|
|
/>
|
|
</KeybindingSetup>
|
|
</AppStateProvider>,
|
|
)
|
|
|
|
return {
|
|
stdin,
|
|
getOutput,
|
|
dispose: async () => {
|
|
root.unmount()
|
|
stdin.end()
|
|
stdout.end()
|
|
await Bun.sleep(0)
|
|
},
|
|
}
|
|
}
|
|
|
|
async function renderProviderManagerFrame(
|
|
ProviderManager: React.ComponentType<{
|
|
mode: 'first-run' | 'manage'
|
|
onDone: (result?: unknown) => void
|
|
}>,
|
|
options?: {
|
|
mode?: 'first-run' | 'manage'
|
|
waitForOutput?: (output: string) => boolean
|
|
timeoutMs?: number
|
|
},
|
|
): Promise<string> {
|
|
const mounted = await mountProviderManager(ProviderManager, {
|
|
mode: options?.mode,
|
|
})
|
|
const output = await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => {
|
|
if (!options?.waitForOutput) {
|
|
return frame.includes('Provider manager')
|
|
}
|
|
return options.waitForOutput(frame)
|
|
},
|
|
options?.timeoutMs ?? 2500,
|
|
)
|
|
|
|
await mounted.dispose()
|
|
return output
|
|
}
|
|
|
|
afterEach(() => {
|
|
mock.restore()
|
|
|
|
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
|
if (value === undefined) {
|
|
delete process.env[key as keyof typeof ORIGINAL_ENV]
|
|
} else {
|
|
process.env[key as keyof typeof ORIGINAL_ENV] = value
|
|
}
|
|
}
|
|
})
|
|
|
|
test('ProviderManager resolves GitHub virtual provider from async storage without sync reads in render flow', async () => {
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const syncRead = mock(() => {
|
|
throw new Error('sync credential read should not run in ProviderManager render flow')
|
|
})
|
|
const asyncRead = mock(async () => 'stored-token')
|
|
|
|
mockProviderManagerDependencies(syncRead, asyncRead)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const output = await renderProviderManagerFrame(ProviderManager, {
|
|
waitForOutput: frame =>
|
|
frame.includes('Provider manager') &&
|
|
frame.includes('GitHub Models') &&
|
|
frame.includes('token stored'),
|
|
})
|
|
|
|
expect(output).toContain('Provider manager')
|
|
expect(output).toContain('GitHub Models')
|
|
expect(output).toContain('token stored')
|
|
expect(output).not.toContain('No provider profiles configured yet.')
|
|
|
|
expect(syncRead).not.toHaveBeenCalled()
|
|
expect(asyncRead).toHaveBeenCalled()
|
|
})
|
|
|
|
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => {
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const syncRead = mock(() => {
|
|
throw new Error('sync credential read should not run in ProviderManager render flow')
|
|
})
|
|
const deferredStoredToken = createDeferred<string | undefined>()
|
|
const asyncRead = mock(async () => deferredStoredToken.promise)
|
|
|
|
mockProviderManagerDependencies(syncRead, asyncRead)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const mounted = await mountProviderManager(ProviderManager)
|
|
|
|
const firstFrame = await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => frame.includes('Provider manager'),
|
|
)
|
|
|
|
expect(firstFrame).toContain('Checking GitHub Models credentials...')
|
|
expect(firstFrame).not.toContain('No provider profiles configured yet.')
|
|
|
|
deferredStoredToken.resolve('stored-token')
|
|
|
|
const resolvedFrame = await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => frame.includes('GitHub Models') && frame.includes('token stored'),
|
|
)
|
|
|
|
expect(resolvedFrame).toContain('GitHub Models')
|
|
expect(resolvedFrame).toContain('token stored')
|
|
|
|
await mounted.dispose()
|
|
|
|
expect(syncRead).not.toHaveBeenCalled()
|
|
expect(asyncRead).toHaveBeenCalled()
|
|
})
|
|
|
|
test('ProviderManager first-run Ollama preset auto-detects installed models', async () => {
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const onDone = mock(() => {})
|
|
const addProviderProfile = mock((payload: {
|
|
provider: string
|
|
name: string
|
|
baseUrl: string
|
|
model: string
|
|
apiKey?: string
|
|
}) => ({
|
|
id: 'provider_ollama',
|
|
provider: payload.provider,
|
|
name: payload.name,
|
|
baseUrl: payload.baseUrl,
|
|
model: payload.model,
|
|
apiKey: payload.apiKey,
|
|
}))
|
|
|
|
mockProviderManagerDependencies(
|
|
() => undefined,
|
|
async () => undefined,
|
|
{
|
|
addProviderProfile,
|
|
probeOllamaGenerationReadiness: async () => ({
|
|
state: 'ready',
|
|
models: [
|
|
{
|
|
name: 'gemma4:31b-cloud',
|
|
family: 'gemma',
|
|
parameterSize: '31b',
|
|
},
|
|
{
|
|
name: 'kimi-k2.5:cloud',
|
|
family: 'kimi',
|
|
parameterSize: '2.5b',
|
|
},
|
|
],
|
|
probeModel: 'gemma4:31b-cloud',
|
|
}),
|
|
},
|
|
)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const mounted = await mountProviderManager(ProviderManager, {
|
|
mode: 'first-run',
|
|
onDone,
|
|
})
|
|
|
|
await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => frame.includes('Set up provider'),
|
|
)
|
|
|
|
await navigateToPreset(mounted.stdin, 'Ollama')
|
|
mounted.stdin.write('\r')
|
|
|
|
const modelFrame = await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame =>
|
|
frame.includes('Choose an Ollama model') &&
|
|
frame.includes('gemma4:31b-cloud') &&
|
|
frame.includes('kimi-k2.5:cloud'),
|
|
)
|
|
|
|
expect(modelFrame).toContain('Choose an Ollama model')
|
|
expect(modelFrame).toContain('gemma4:31b-cloud')
|
|
|
|
await Bun.sleep(25)
|
|
mounted.stdin.write('\r')
|
|
|
|
await waitForCondition(() => onDone.mock.calls.length > 0)
|
|
|
|
expect(addProviderProfile).toHaveBeenCalled()
|
|
expect(addProviderProfile.mock.calls[0]?.[0]).toMatchObject({
|
|
name: 'Ollama',
|
|
baseUrl: 'http://localhost:11434/v1',
|
|
model: 'gemma4:31b-cloud',
|
|
})
|
|
expect(onDone).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'saved',
|
|
message: 'Provider configured: Ollama',
|
|
}),
|
|
)
|
|
|
|
await mounted.dispose()
|
|
})
|
|
|
|
test('ProviderManager first-run Codex OAuth switches the current session after login completes', async () => {
|
|
delete process.env.CLAUDE_CODE_SIMPLE
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const onDone = mock(() => {})
|
|
const applySavedProfileToCurrentSession = mock(async () => null)
|
|
const persistCredentials = mock(() => {})
|
|
const addProviderProfile = mock((payload: {
|
|
provider: string
|
|
name: string
|
|
baseUrl: string
|
|
model: string
|
|
apiKey?: string
|
|
}) => ({
|
|
id: 'provider_codex_oauth',
|
|
provider: payload.provider,
|
|
name: payload.name,
|
|
baseUrl: payload.baseUrl,
|
|
model: payload.model,
|
|
apiKey: payload.apiKey,
|
|
}))
|
|
|
|
mockProviderManagerDependencies(
|
|
() => undefined,
|
|
async () => undefined,
|
|
{
|
|
addProviderProfile,
|
|
applySavedProfileToCurrentSession,
|
|
useCodexOAuthFlow: ({ onAuthenticated }) => {
|
|
React.useEffect(() => {
|
|
void onAuthenticated({
|
|
accessToken: 'oauth-access-token',
|
|
refreshToken: 'oauth-refresh-token',
|
|
accountId: 'acct_oauth',
|
|
}, persistCredentials)
|
|
}, [onAuthenticated])
|
|
|
|
return {
|
|
state: 'waiting',
|
|
authUrl: 'https://chatgpt.com/codex',
|
|
browserOpened: true,
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const mounted = await mountProviderManager(ProviderManager, {
|
|
mode: 'first-run',
|
|
onDone,
|
|
})
|
|
|
|
await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
|
)
|
|
|
|
await navigateToPreset(mounted.stdin, 'Codex OAuth')
|
|
mounted.stdin.write('\r')
|
|
|
|
await waitForCondition(() => onDone.mock.calls.length > 0)
|
|
|
|
expect(addProviderProfile).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
provider: 'openai',
|
|
name: 'Codex OAuth',
|
|
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
|
model: 'codexplan',
|
|
apiKey: '',
|
|
}),
|
|
expect.objectContaining({ makeActive: true }),
|
|
)
|
|
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
|
|
expect(persistCredentials).toHaveBeenCalledWith({
|
|
profileId: 'provider_codex_oauth',
|
|
})
|
|
expect(onDone).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'saved',
|
|
message:
|
|
'Codex OAuth configured. OpenClaude switched to it for this session.',
|
|
}),
|
|
)
|
|
|
|
await mounted.dispose()
|
|
})
|
|
|
|
test('ProviderManager first-run Codex OAuth reports next-startup fallback when session activation fails', async () => {
|
|
delete process.env.CLAUDE_CODE_SIMPLE
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const onDone = mock(() => {})
|
|
const applySavedProfileToCurrentSession = mock(
|
|
async () => 'validation failed',
|
|
)
|
|
const persistCredentials = mock(() => {})
|
|
const addProviderProfile = mock((payload: {
|
|
provider: string
|
|
name: string
|
|
baseUrl: string
|
|
model: string
|
|
apiKey?: string
|
|
}) => ({
|
|
id: 'provider_codex_oauth',
|
|
provider: payload.provider,
|
|
name: payload.name,
|
|
baseUrl: payload.baseUrl,
|
|
model: payload.model,
|
|
apiKey: payload.apiKey,
|
|
}))
|
|
|
|
mockProviderManagerDependencies(
|
|
() => undefined,
|
|
async () => undefined,
|
|
{
|
|
addProviderProfile,
|
|
applySavedProfileToCurrentSession,
|
|
useCodexOAuthFlow: ({ onAuthenticated }) => {
|
|
React.useEffect(() => {
|
|
void onAuthenticated({
|
|
accessToken: 'oauth-access-token',
|
|
refreshToken: 'oauth-refresh-token',
|
|
accountId: 'acct_oauth',
|
|
}, persistCredentials)
|
|
}, [onAuthenticated])
|
|
|
|
return {
|
|
state: 'waiting',
|
|
authUrl: 'https://chatgpt.com/codex',
|
|
browserOpened: true,
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const mounted = await mountProviderManager(ProviderManager, {
|
|
mode: 'first-run',
|
|
onDone,
|
|
})
|
|
|
|
await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
|
)
|
|
|
|
await navigateToPreset(mounted.stdin, 'Codex OAuth')
|
|
mounted.stdin.write('\r')
|
|
|
|
await waitForCondition(() => onDone.mock.calls.length > 0)
|
|
|
|
expect(persistCredentials).toHaveBeenCalledWith({
|
|
profileId: 'provider_codex_oauth',
|
|
})
|
|
expect(onDone).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: 'saved',
|
|
message:
|
|
'Codex OAuth configured. Saved for next startup. Warning: validation failed.',
|
|
}),
|
|
)
|
|
|
|
await mounted.dispose()
|
|
})
|
|
|
|
test('ProviderManager does not hijack a manual Codex profile when OAuth credentials are not yet linked', async () => {
|
|
delete process.env.CLAUDE_CODE_SIMPLE
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const onDone = mock(() => {})
|
|
const manualProfile = {
|
|
id: 'provider_manual_codex',
|
|
provider: 'openai',
|
|
name: 'Codex OAuth',
|
|
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
|
model: 'gpt-5.4',
|
|
apiKey: 'manual-key',
|
|
}
|
|
const addProviderProfile = mock((payload: {
|
|
provider: string
|
|
name: string
|
|
baseUrl: string
|
|
model: string
|
|
apiKey?: string
|
|
}) => ({
|
|
id: 'provider_codex_oauth',
|
|
provider: payload.provider,
|
|
name: payload.name,
|
|
baseUrl: payload.baseUrl,
|
|
model: payload.model,
|
|
apiKey: payload.apiKey,
|
|
}))
|
|
const updateProviderProfile = mock(() => manualProfile)
|
|
const persistCredentials = mock(() => {})
|
|
|
|
mockProviderManagerDependencies(
|
|
() => undefined,
|
|
async () => undefined,
|
|
{
|
|
addProviderProfile,
|
|
getProviderProfiles: () => [manualProfile],
|
|
updateProviderProfile,
|
|
useCodexOAuthFlow: ({ onAuthenticated }) => {
|
|
const hasAuthenticated = React.useRef(false)
|
|
|
|
React.useEffect(() => {
|
|
if (hasAuthenticated.current) {
|
|
return
|
|
}
|
|
hasAuthenticated.current = true
|
|
void onAuthenticated({
|
|
accessToken: 'oauth-access-token',
|
|
refreshToken: 'oauth-refresh-token',
|
|
accountId: 'acct_oauth',
|
|
}, persistCredentials)
|
|
}, [onAuthenticated])
|
|
|
|
return {
|
|
state: 'waiting',
|
|
authUrl: 'https://chatgpt.com/codex',
|
|
browserOpened: true,
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const mounted = await mountProviderManager(ProviderManager, {
|
|
mode: 'first-run',
|
|
onDone,
|
|
})
|
|
|
|
await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
|
)
|
|
|
|
await navigateToPreset(mounted.stdin, 'Codex OAuth')
|
|
mounted.stdin.write('\r')
|
|
|
|
await waitForCondition(() => onDone.mock.calls.length > 0)
|
|
|
|
expect(addProviderProfile).toHaveBeenCalledTimes(1)
|
|
expect(updateProviderProfile).not.toHaveBeenCalled()
|
|
expect(persistCredentials).toHaveBeenCalledWith({
|
|
profileId: 'provider_codex_oauth',
|
|
})
|
|
|
|
await mounted.dispose()
|
|
})
|
|
|
|
test('ProviderManager keeps Codex OAuth as next-startup only when activating the session fails from the menu', async () => {
|
|
delete process.env.CLAUDE_CODE_SIMPLE
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const codexProfile = {
|
|
id: 'provider_codex_oauth',
|
|
provider: 'openai',
|
|
name: 'Codex OAuth',
|
|
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
|
model: 'codexplan',
|
|
apiKey: '',
|
|
}
|
|
|
|
const applySavedProfileToCurrentSession = mock(
|
|
async () => 'validation failed',
|
|
)
|
|
const setActiveProviderProfile = mock(() => codexProfile)
|
|
|
|
mockProviderManagerDependencies(
|
|
() => undefined,
|
|
async () => undefined,
|
|
{
|
|
applySavedProfileToCurrentSession,
|
|
getProviderProfiles: () => [codexProfile],
|
|
setActiveProviderProfile,
|
|
codexAsyncRead: async () => ({
|
|
accessToken: 'oauth-access-token',
|
|
refreshToken: 'oauth-refresh-token',
|
|
accountId: 'acct_oauth',
|
|
profileId: 'provider_codex_oauth',
|
|
}),
|
|
},
|
|
)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const mounted = await mountProviderManager(ProviderManager)
|
|
|
|
await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame =>
|
|
frame.includes('Provider manager') &&
|
|
frame.includes('Set active provider') &&
|
|
frame.includes('Log out Codex OAuth'),
|
|
)
|
|
|
|
mounted.stdin.write('j')
|
|
await Bun.sleep(25)
|
|
mounted.stdin.write('\r')
|
|
|
|
await waitForFrameOutput(
|
|
mounted.getOutput,
|
|
frame => frame.includes('Set active provider') && frame.includes('Codex OAuth'),
|
|
)
|
|
|
|
await Bun.sleep(25)
|
|
mounted.stdin.write('\r')
|
|
|
|
await waitForCondition(() => setActiveProviderProfile.mock.calls.length > 0)
|
|
await waitForCondition(
|
|
() => applySavedProfileToCurrentSession.mock.calls.length > 0,
|
|
)
|
|
await Bun.sleep(50)
|
|
const output = stripAnsi(extractLastFrame(mounted.getOutput()))
|
|
|
|
expect(output).toContain(
|
|
'Active provider: Codex OAuth. Saved for next startup. Warning: validation failed.',
|
|
)
|
|
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
|
|
expect(setActiveProviderProfile).toHaveBeenCalledWith('provider_codex_oauth')
|
|
|
|
await mounted.dispose()
|
|
})
|
|
|
|
test('ProviderManager resolves Codex OAuth state from async storage without sync reads in render flow', async () => {
|
|
delete process.env.CLAUDE_CODE_SIMPLE
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const githubSyncRead = mock(() => undefined)
|
|
const githubAsyncRead = mock(async () => undefined)
|
|
const codexSyncRead = mock(() => {
|
|
throw new Error('sync codex credential read should not run in ProviderManager render flow')
|
|
})
|
|
const codexAsyncRead = mock(async () => ({
|
|
accessToken: 'codex-access-token',
|
|
refreshToken: 'codex-refresh-token',
|
|
}))
|
|
|
|
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead, {
|
|
codexSyncRead,
|
|
codexAsyncRead,
|
|
})
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const output = await renderProviderManagerFrame(ProviderManager, {
|
|
waitForOutput: frame =>
|
|
frame.includes('Provider manager') &&
|
|
frame.includes('Log out Codex OAuth'),
|
|
})
|
|
|
|
expect(output).toContain('Provider manager')
|
|
expect(output).toContain('Log out Codex OAuth')
|
|
expect(codexSyncRead).not.toHaveBeenCalled()
|
|
expect(codexAsyncRead).toHaveBeenCalled()
|
|
})
|
|
|
|
test('ProviderManager hides Codex OAuth setup in bare mode', async () => {
|
|
process.env.CLAUDE_CODE_SIMPLE = '1'
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
|
|
const githubSyncRead = mock(() => undefined)
|
|
const githubAsyncRead = mock(async () => undefined)
|
|
|
|
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead)
|
|
|
|
const nonce = `${Date.now()}-${Math.random()}`
|
|
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
|
const output = await renderProviderManagerFrame(ProviderManager, {
|
|
mode: 'first-run',
|
|
waitForOutput: frame =>
|
|
frame.includes('Set up provider') && frame.includes('OpenAI'),
|
|
})
|
|
|
|
expect(output).toContain('Set up provider')
|
|
expect(output).not.toContain('Codex OAuth')
|
|
})
|