feat: add guided /provider setup
This commit is contained in:
@@ -1,6 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { writeFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
import {
|
import {
|
||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
} from '../src/services/api/providerConfig.js'
|
} from '../src/services/api/providerConfig.js'
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
buildOllamaProfileEnv,
|
buildOllamaProfileEnv,
|
||||||
buildOpenAIProfileEnv,
|
buildOpenAIProfileEnv,
|
||||||
createProfileFile,
|
createProfileFile,
|
||||||
|
saveProfileFile,
|
||||||
selectAutoProfile,
|
selectAutoProfile,
|
||||||
type ProfileFile,
|
type ProfileFile,
|
||||||
type ProviderProfile,
|
type ProviderProfile,
|
||||||
@@ -147,8 +146,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const profile = createProfileFile(selected, env)
|
const profile = createProfileFile(selected, env)
|
||||||
|
|
||||||
const outputPath = resolve(process.cwd(), '.openclaude-profile.json')
|
const outputPath = saveProfileFile(profile)
|
||||||
writeFileSync(outputPath, JSON.stringify(profile, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
||||||
|
|
||||||
console.log(`Saved profile: ${selected}`)
|
console.log(`Saved profile: ${selected}`)
|
||||||
console.log(`Goal: ${goal}`)
|
console.log(`Goal: ${goal}`)
|
||||||
|
|||||||
@@ -1,129 +1,8 @@
|
|||||||
import type { OllamaModelDescriptor } from '../src/utils/providerRecommendation.ts'
|
export {
|
||||||
|
benchmarkOllamaModel,
|
||||||
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'
|
DEFAULT_OLLAMA_BASE_URL,
|
||||||
|
getOllamaApiBaseUrl,
|
||||||
function withTimeoutSignal(timeoutMs: number): {
|
getOllamaChatBaseUrl,
|
||||||
signal: AbortSignal
|
hasLocalOllama,
|
||||||
clear: () => void
|
listOllamaModels,
|
||||||
} {
|
} from '../src/utils/providerDiscovery.ts'
|
||||||
const controller = new AbortController()
|
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
|
||||||
return {
|
|
||||||
signal: controller.signal,
|
|
||||||
clear: () => clearTimeout(timeout),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimTrailingSlash(value: string): string {
|
|
||||||
return value.replace(/\/+$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOllamaApiBaseUrl(baseUrl?: string): string {
|
|
||||||
const parsed = new URL(
|
|
||||||
baseUrl || process.env.OLLAMA_BASE_URL || DEFAULT_OLLAMA_BASE_URL,
|
|
||||||
)
|
|
||||||
const pathname = trimTrailingSlash(parsed.pathname)
|
|
||||||
parsed.pathname = pathname.endsWith('/v1')
|
|
||||||
? pathname.slice(0, -3) || '/'
|
|
||||||
: pathname || '/'
|
|
||||||
parsed.search = ''
|
|
||||||
parsed.hash = ''
|
|
||||||
return trimTrailingSlash(parsed.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOllamaChatBaseUrl(baseUrl?: string): string {
|
|
||||||
return `${getOllamaApiBaseUrl(baseUrl)}/v1`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hasLocalOllama(baseUrl?: string): Promise<boolean> {
|
|
||||||
const { signal, clear } = withTimeoutSignal(1200)
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/tags`, {
|
|
||||||
method: 'GET',
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
return response.ok
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listOllamaModels(
|
|
||||||
baseUrl?: string,
|
|
||||||
): Promise<OllamaModelDescriptor[]> {
|
|
||||||
const { signal, clear } = withTimeoutSignal(5000)
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/tags`, {
|
|
||||||
method: 'GET',
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as {
|
|
||||||
models?: Array<{
|
|
||||||
name?: string
|
|
||||||
size?: number
|
|
||||||
details?: {
|
|
||||||
family?: string
|
|
||||||
families?: string[]
|
|
||||||
parameter_size?: string
|
|
||||||
quantization_level?: string
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data.models ?? [])
|
|
||||||
.filter(model => Boolean(model.name))
|
|
||||||
.map(model => ({
|
|
||||||
name: model.name!,
|
|
||||||
sizeBytes: typeof model.size === 'number' ? model.size : null,
|
|
||||||
family: model.details?.family ?? null,
|
|
||||||
families: model.details?.families ?? [],
|
|
||||||
parameterSize: model.details?.parameter_size ?? null,
|
|
||||||
quantizationLevel: model.details?.quantization_level ?? null,
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
} finally {
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function benchmarkOllamaModel(
|
|
||||||
modelName: string,
|
|
||||||
baseUrl?: string,
|
|
||||||
): Promise<number | null> {
|
|
||||||
const start = Date.now()
|
|
||||||
const { signal, clear } = withTimeoutSignal(20000)
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: modelName,
|
|
||||||
stream: false,
|
|
||||||
messages: [{ role: 'user', content: 'Reply with OK.' }],
|
|
||||||
options: {
|
|
||||||
temperature: 0,
|
|
||||||
num_predict: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
await response.json()
|
|
||||||
return Date.now() - start
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
import {
|
import {
|
||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
} from '../src/services/api/providerConfig.js'
|
} from '../src/services/api/providerConfig.js'
|
||||||
@@ -11,6 +9,7 @@ import {
|
|||||||
} from '../src/utils/providerRecommendation.ts'
|
} from '../src/utils/providerRecommendation.ts'
|
||||||
import {
|
import {
|
||||||
buildLaunchEnv,
|
buildLaunchEnv,
|
||||||
|
loadProfileFile,
|
||||||
selectAutoProfile,
|
selectAutoProfile,
|
||||||
type ProfileFile,
|
type ProfileFile,
|
||||||
type ProviderProfile,
|
type ProviderProfile,
|
||||||
@@ -75,17 +74,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadPersistedProfile(): ProfileFile | null {
|
function loadPersistedProfile(): ProfileFile | null {
|
||||||
const path = resolve(process.cwd(), '.openclaude-profile.json')
|
return loadProfileFile()
|
||||||
if (!existsSync(path)) return null
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as ProfileFile
|
|
||||||
if (parsed.profile === 'openai' || parsed.profile === 'ollama' || parsed.profile === 'codex' || parsed.profile === 'gemini') {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveOllamaDefaultModel(
|
async function resolveOllamaDefaultModel(
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { writeFileSync } from 'node:fs'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyBenchmarkLatency,
|
applyBenchmarkLatency,
|
||||||
@@ -16,6 +14,7 @@ import {
|
|||||||
buildOllamaProfileEnv,
|
buildOllamaProfileEnv,
|
||||||
buildOpenAIProfileEnv,
|
buildOpenAIProfileEnv,
|
||||||
createProfileFile,
|
createProfileFile,
|
||||||
|
saveProfileFile,
|
||||||
sanitizeApiKey,
|
sanitizeApiKey,
|
||||||
type ProfileFile,
|
type ProfileFile,
|
||||||
type ProviderProfile,
|
type ProviderProfile,
|
||||||
@@ -153,11 +152,7 @@ async function maybeApplyProfile(
|
|||||||
|
|
||||||
const profileFile = createProfileFile(profile, env)
|
const profileFile = createProfileFile(profile, env)
|
||||||
|
|
||||||
writeFileSync(
|
saveProfileFile(profileFile)
|
||||||
resolve(process.cwd(), '.openclaude-profile.json'),
|
|
||||||
JSON.stringify(profileFile, null, 2),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ import plan from './commands/plan/index.js'
|
|||||||
import fast from './commands/fast/index.js'
|
import fast from './commands/fast/index.js'
|
||||||
import passes from './commands/passes/index.js'
|
import passes from './commands/passes/index.js'
|
||||||
import privacySettings from './commands/privacy-settings/index.js'
|
import privacySettings from './commands/privacy-settings/index.js'
|
||||||
|
import provider from './commands/provider/index.js'
|
||||||
import hooks from './commands/hooks/index.js'
|
import hooks from './commands/hooks/index.js'
|
||||||
import files from './commands/files/index.js'
|
import files from './commands/files/index.js'
|
||||||
import branch from './commands/branch/index.js'
|
import branch from './commands/branch/index.js'
|
||||||
@@ -291,6 +292,7 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
outputStyle,
|
outputStyle,
|
||||||
remoteEnv,
|
remoteEnv,
|
||||||
plugin,
|
plugin,
|
||||||
|
provider,
|
||||||
pr_comments,
|
pr_comments,
|
||||||
releaseNotes,
|
releaseNotes,
|
||||||
reloadPlugins,
|
reloadPlugins,
|
||||||
|
|||||||
12
src/commands/provider/index.ts
Normal file
12
src/commands/provider/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Command } from '../../commands.js'
|
||||||
|
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
type: 'local-jsx',
|
||||||
|
name: 'provider',
|
||||||
|
description: 'Set up and save a third-party provider profile for OpenClaude',
|
||||||
|
get immediate() {
|
||||||
|
return shouldInferenceConfigCommandBeImmediate()
|
||||||
|
},
|
||||||
|
load: () => import('./provider.js'),
|
||||||
|
} satisfies Command
|
||||||
228
src/commands/provider/provider.test.tsx
Normal file
228
src/commands/provider/provider.test.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { PassThrough } from 'node:stream'
|
||||||
|
|
||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
import React from 'react'
|
||||||
|
import stripAnsi from 'strip-ansi'
|
||||||
|
|
||||||
|
import { createRoot, render, useApp } from '../../ink.js'
|
||||||
|
import { AppStateProvider } from '../../state/AppState.js'
|
||||||
|
import {
|
||||||
|
buildCurrentProviderSummary,
|
||||||
|
buildProfileSaveMessage,
|
||||||
|
getProviderWizardDefaults,
|
||||||
|
TextEntryDialog,
|
||||||
|
} from './provider.js'
|
||||||
|
|
||||||
|
const SYNC_START = '\x1B[?2026h'
|
||||||
|
const SYNC_END = '\x1B[?2026l'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFinalFrame(node: React.ReactNode): Promise<string> {
|
||||||
|
let output = ''
|
||||||
|
const { stdout, stdin, getOutput } = createTestStreams()
|
||||||
|
|
||||||
|
const instance = await render(node, {
|
||||||
|
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||||
|
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||||
|
patchConsole: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await instance.waitUntilExit()
|
||||||
|
return stripAnsi(extractLastFrame(getOutput()))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepChangeHarness(): React.ReactNode {
|
||||||
|
const { exit } = useApp()
|
||||||
|
const [step, setStep] = React.useState<'api' | 'model'>('api')
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (step === 'api') {
|
||||||
|
setStep('model')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(exit, 0)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [exit, step])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppStateProvider>
|
||||||
|
<TextEntryDialog
|
||||||
|
title="Provider"
|
||||||
|
subtitle={step === 'api' ? 'API key step' : 'Model step'}
|
||||||
|
description="Enter the next value"
|
||||||
|
initialValue={step === 'api' ? 'stale-secret-key' : 'fresh-model-name'}
|
||||||
|
mask={step === 'api' ? '*' : undefined}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>
|
||||||
|
</AppStateProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('TextEntryDialog resets its input state when initialValue changes', async () => {
|
||||||
|
const output = await renderFinalFrame(<StepChangeHarness />)
|
||||||
|
|
||||||
|
expect(output).toContain('Model step')
|
||||||
|
expect(output).toContain('fresh-model-name')
|
||||||
|
expect(output).not.toContain('stale-secret-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wizard step remount prevents a typed API key from leaking into the next field', async () => {
|
||||||
|
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>
|
||||||
|
<TextEntryDialog
|
||||||
|
resetStateKey="api"
|
||||||
|
title="Provider"
|
||||||
|
subtitle="API key step"
|
||||||
|
description="Enter the API key"
|
||||||
|
initialValue=""
|
||||||
|
mask="*"
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>
|
||||||
|
</AppStateProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.sleep(25)
|
||||||
|
stdin.write('sk-secret-12345678')
|
||||||
|
await Bun.sleep(25)
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<AppStateProvider>
|
||||||
|
<TextEntryDialog
|
||||||
|
resetStateKey="model"
|
||||||
|
title="Provider"
|
||||||
|
subtitle="Model step"
|
||||||
|
description="Enter the model"
|
||||||
|
initialValue=""
|
||||||
|
onSubmit={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>
|
||||||
|
</AppStateProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.sleep(25)
|
||||||
|
root.unmount()
|
||||||
|
stdin.end()
|
||||||
|
stdout.end()
|
||||||
|
await Bun.sleep(25)
|
||||||
|
|
||||||
|
const output = stripAnsi(extractLastFrame(getOutput()))
|
||||||
|
expect(output).toContain('Model step')
|
||||||
|
expect(output).not.toContain('sk-secret-12345678')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildProfileSaveMessage maps provider fields without echoing secrets', () => {
|
||||||
|
const message = buildProfileSaveMessage(
|
||||||
|
'openai',
|
||||||
|
{
|
||||||
|
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||||
|
OPENAI_MODEL: 'gpt-4o',
|
||||||
|
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
},
|
||||||
|
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(message).toContain('Saved OpenAI-compatible profile.')
|
||||||
|
expect(message).toContain('Model: gpt-4o')
|
||||||
|
expect(message).toContain('Endpoint: https://api.openai.com/v1')
|
||||||
|
expect(message).toContain('Credentials: configured')
|
||||||
|
expect(message).not.toContain('sk-secret-12345678')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
|
||||||
|
const summary = buildCurrentProviderSummary({
|
||||||
|
processEnv: {
|
||||||
|
CLAUDE_CODE_USE_OPENAI: '1',
|
||||||
|
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||||
|
OPENAI_MODEL: 'sk-secret-12345678',
|
||||||
|
OPENAI_BASE_URL: 'sk-secret-12345678',
|
||||||
|
},
|
||||||
|
persisted: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary.providerLabel).toBe('OpenAI-compatible')
|
||||||
|
expect(summary.modelLabel).toBe('sk-...5678')
|
||||||
|
expect(summary.endpointLabel).toBe('sk-...5678')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getProviderWizardDefaults ignores poisoned current provider values', () => {
|
||||||
|
const defaults = getProviderWizardDefaults({
|
||||||
|
OPENAI_API_KEY: 'sk-secret-12345678',
|
||||||
|
OPENAI_MODEL: 'sk-secret-12345678',
|
||||||
|
OPENAI_BASE_URL: 'sk-secret-12345678',
|
||||||
|
GEMINI_API_KEY: 'AIzaSecret12345678',
|
||||||
|
GEMINI_MODEL: 'AIzaSecret12345678',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(defaults.openAIModel).toBe('gpt-4o')
|
||||||
|
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
|
||||||
|
expect(defaults.geminiModel).toBe('gemini-2.0-flash')
|
||||||
|
})
|
||||||
1148
src/commands/provider/provider.tsx
Normal file
1148
src/commands/provider/provider.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -3,6 +3,11 @@ import {
|
|||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
} from '../services/api/providerConfig.js'
|
} from '../services/api/providerConfig.js'
|
||||||
|
import {
|
||||||
|
applyProfileEnvToProcessEnv,
|
||||||
|
buildStartupEnvFromProfile,
|
||||||
|
redactSecretValueForDisplay,
|
||||||
|
} from '../utils/providerProfile.js'
|
||||||
|
|
||||||
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
||||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||||
@@ -45,39 +50,57 @@ function isLocalProviderUrl(baseUrl: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateProviderEnvOrExit(): void {
|
function getProviderValidationError(
|
||||||
if (!isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) {
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
return
|
): string | null {
|
||||||
|
if (isEnvTruthy(env.CLAUDE_CODE_USE_GEMINI)) {
|
||||||
|
if (!(env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY)) {
|
||||||
|
return 'GEMINI_API_KEY is required when CLAUDE_CODE_USE_GEMINI=1.'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = resolveProviderRequest({
|
const request = resolveProviderRequest({
|
||||||
model: process.env.OPENAI_MODEL,
|
model: env.OPENAI_MODEL,
|
||||||
baseUrl: process.env.OPENAI_BASE_URL,
|
baseUrl: env.OPENAI_BASE_URL,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (process.env.OPENAI_API_KEY === 'SUA_CHAVE') {
|
if (env.OPENAI_API_KEY === 'SUA_CHAVE') {
|
||||||
console.error('Invalid OPENAI_API_KEY: placeholder value SUA_CHAVE detected. Set a real key or unset for local providers.')
|
return 'Invalid OPENAI_API_KEY: placeholder value SUA_CHAVE detected. Set a real key or unset for local providers.'
|
||||||
process.exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.transport === 'codex_responses') {
|
if (request.transport === 'codex_responses') {
|
||||||
const credentials = resolveCodexApiCredentials()
|
const credentials = resolveCodexApiCredentials(env)
|
||||||
if (!credentials.apiKey) {
|
if (!credentials.apiKey) {
|
||||||
const authHint = credentials.authPath
|
const authHint = credentials.authPath
|
||||||
? ` or put auth.json at ${credentials.authPath}`
|
? ` or put auth.json at ${credentials.authPath}`
|
||||||
: ''
|
: ''
|
||||||
console.error(`Codex auth is required for ${request.requestedModel}. Set CODEX_API_KEY${authHint}.`)
|
const safeModel =
|
||||||
process.exit(1)
|
redactSecretValueForDisplay(request.requestedModel, env) ??
|
||||||
|
'the requested model'
|
||||||
|
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
|
||||||
}
|
}
|
||||||
if (!credentials.accountId) {
|
if (!credentials.accountId) {
|
||||||
console.error('Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.')
|
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
|
||||||
process.exit(1)
|
|
||||||
}
|
}
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
|
if (!env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
|
||||||
console.error('OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.')
|
return 'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProviderEnvOrExit(): void {
|
||||||
|
const error = getProviderValidationError()
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +121,20 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startupEnv = await buildStartupEnvFromProfile({
|
||||||
|
processEnv: process.env,
|
||||||
|
})
|
||||||
|
if (startupEnv !== process.env) {
|
||||||
|
const startupProfileError = getProviderValidationError(startupEnv)
|
||||||
|
if (startupProfileError) {
|
||||||
|
console.error(
|
||||||
|
`Warning: ignoring saved provider profile. ${startupProfileError}`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
applyProfileEnvToProcessEnv(process.env, startupEnv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
validateProviderEnvOrExit()
|
validateProviderEnvOrExit()
|
||||||
|
|
||||||
// Print the gradient startup screen before the Ink UI loads
|
// Print the gradient startup screen before the Ink UI loads
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
} from './providerConfig.js'
|
} from './providerConfig.js'
|
||||||
|
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types — minimal subset of Anthropic SDK types we need to produce
|
// Types — minimal subset of Anthropic SDK types we need to produce
|
||||||
@@ -612,8 +613,11 @@ class OpenAIShimMessages {
|
|||||||
const authHint = credentials.authPath
|
const authHint = credentials.authPath
|
||||||
? ` or place a Codex auth.json at ${credentials.authPath}`
|
? ` or place a Codex auth.json at ${credentials.authPath}`
|
||||||
: ''
|
: ''
|
||||||
|
const safeModel =
|
||||||
|
redactSecretValueForDisplay(request.requestedModel, process.env) ??
|
||||||
|
'the requested model'
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Codex auth is required for ${request.requestedModel}. Set CODEX_API_KEY${authHint}.`,
|
`Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!credentials.accountId) {
|
if (!credentials.accountId) {
|
||||||
|
|||||||
129
src/utils/providerDiscovery.ts
Normal file
129
src/utils/providerDiscovery.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { OllamaModelDescriptor } from './providerRecommendation.ts'
|
||||||
|
|
||||||
|
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'
|
||||||
|
|
||||||
|
function withTimeoutSignal(timeoutMs: number): {
|
||||||
|
signal: AbortSignal
|
||||||
|
clear: () => void
|
||||||
|
} {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
return {
|
||||||
|
signal: controller.signal,
|
||||||
|
clear: () => clearTimeout(timeout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingSlash(value: string): string {
|
||||||
|
return value.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOllamaApiBaseUrl(baseUrl?: string): string {
|
||||||
|
const parsed = new URL(
|
||||||
|
baseUrl || process.env.OLLAMA_BASE_URL || DEFAULT_OLLAMA_BASE_URL,
|
||||||
|
)
|
||||||
|
const pathname = trimTrailingSlash(parsed.pathname)
|
||||||
|
parsed.pathname = pathname.endsWith('/v1')
|
||||||
|
? pathname.slice(0, -3) || '/'
|
||||||
|
: pathname || '/'
|
||||||
|
parsed.search = ''
|
||||||
|
parsed.hash = ''
|
||||||
|
return trimTrailingSlash(parsed.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOllamaChatBaseUrl(baseUrl?: string): string {
|
||||||
|
return `${getOllamaApiBaseUrl(baseUrl)}/v1`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasLocalOllama(baseUrl?: string): Promise<boolean> {
|
||||||
|
const { signal, clear } = withTimeoutSignal(1200)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOllamaModels(
|
||||||
|
baseUrl?: string,
|
||||||
|
): Promise<OllamaModelDescriptor[]> {
|
||||||
|
const { signal, clear } = withTimeoutSignal(5000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
models?: Array<{
|
||||||
|
name?: string
|
||||||
|
size?: number
|
||||||
|
details?: {
|
||||||
|
family?: string
|
||||||
|
families?: string[]
|
||||||
|
parameter_size?: string
|
||||||
|
quantization_level?: string
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data.models ?? [])
|
||||||
|
.filter(model => Boolean(model.name))
|
||||||
|
.map(model => ({
|
||||||
|
name: model.name!,
|
||||||
|
sizeBytes: typeof model.size === 'number' ? model.size : null,
|
||||||
|
family: model.details?.family ?? null,
|
||||||
|
families: model.details?.families ?? [],
|
||||||
|
parameterSize: model.details?.parameter_size ?? null,
|
||||||
|
quantizationLevel: model.details?.quantization_level ?? null,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function benchmarkOllamaModel(
|
||||||
|
modelName: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const start = Date.now()
|
||||||
|
const { signal, clear } = withTimeoutSignal(20000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getOllamaApiBaseUrl(baseUrl)}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: modelName,
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'Reply with OK.' }],
|
||||||
|
options: {
|
||||||
|
temperature: 0,
|
||||||
|
num_predict: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
await response.json()
|
||||||
|
return Date.now() - start
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildStartupEnvFromProfile,
|
||||||
buildCodexProfileEnv,
|
buildCodexProfileEnv,
|
||||||
buildGeminiProfileEnv,
|
buildGeminiProfileEnv,
|
||||||
buildLaunchEnv,
|
buildLaunchEnv,
|
||||||
buildOllamaProfileEnv,
|
buildOllamaProfileEnv,
|
||||||
buildOpenAIProfileEnv,
|
buildOpenAIProfileEnv,
|
||||||
|
createProfileFile,
|
||||||
|
maskSecretForDisplay,
|
||||||
|
loadProfileFile,
|
||||||
|
PROFILE_FILE_NAME,
|
||||||
|
redactSecretValueForDisplay,
|
||||||
|
saveProfileFile,
|
||||||
|
sanitizeProviderConfigValue,
|
||||||
selectAutoProfile,
|
selectAutoProfile,
|
||||||
type ProfileFile,
|
type ProfileFile,
|
||||||
} from './providerProfile.ts'
|
} from './providerProfile.ts'
|
||||||
@@ -359,6 +367,112 @@ test('gemini profiles require a key', () => {
|
|||||||
assert.equal(env, null)
|
assert.equal(env, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('saveProfileFile writes a profile that loadProfileFile can read back', () => {
|
||||||
|
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-profile-file-'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const persisted = createProfileFile('openai', {
|
||||||
|
OPENAI_API_KEY: 'sk-test',
|
||||||
|
OPENAI_MODEL: 'gpt-4o',
|
||||||
|
})
|
||||||
|
|
||||||
|
const filePath = saveProfileFile(persisted, { cwd })
|
||||||
|
|
||||||
|
assert.equal(filePath, join(cwd, PROFILE_FILE_NAME))
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(readFileSync(filePath, 'utf8')).profile,
|
||||||
|
'openai',
|
||||||
|
)
|
||||||
|
assert.deepEqual(loadProfileFile({ cwd }), persisted)
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildStartupEnvFromProfile applies persisted gemini settings when no provider is explicitly selected', async () => {
|
||||||
|
const env = await buildStartupEnvFromProfile({
|
||||||
|
persisted: profile('gemini', {
|
||||||
|
GEMINI_API_KEY: 'gem-test',
|
||||||
|
GEMINI_MODEL: 'gemini-2.5-flash',
|
||||||
|
}),
|
||||||
|
processEnv: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
|
||||||
|
assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined)
|
||||||
|
assert.equal(env.GEMINI_API_KEY, 'gem-test')
|
||||||
|
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildStartupEnvFromProfile leaves explicit provider selections untouched', async () => {
|
||||||
|
const processEnv = {
|
||||||
|
CLAUDE_CODE_USE_GEMINI: '1',
|
||||||
|
GEMINI_API_KEY: 'gem-live',
|
||||||
|
GEMINI_MODEL: 'gemini-2.0-flash',
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = await buildStartupEnvFromProfile({
|
||||||
|
persisted: profile('openai', {
|
||||||
|
OPENAI_API_KEY: 'sk-persisted',
|
||||||
|
OPENAI_MODEL: 'gpt-4o',
|
||||||
|
}),
|
||||||
|
processEnv,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(env, processEnv)
|
||||||
|
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
|
||||||
|
assert.equal(env.OPENAI_API_KEY, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildStartupEnvFromProfile treats explicit falsey provider flags as user intent', async () => {
|
||||||
|
const processEnv = {
|
||||||
|
CLAUDE_CODE_USE_OPENAI: '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = await buildStartupEnvFromProfile({
|
||||||
|
persisted: profile('gemini', {
|
||||||
|
GEMINI_API_KEY: 'gem-persisted',
|
||||||
|
GEMINI_MODEL: 'gemini-2.5-flash',
|
||||||
|
}),
|
||||||
|
processEnv,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(env, processEnv)
|
||||||
|
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '0')
|
||||||
|
assert.equal(env.GEMINI_API_KEY, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maskSecretForDisplay preserves only a short prefix and suffix', () => {
|
||||||
|
assert.equal(maskSecretForDisplay('sk-secret-12345678'), 'sk-...5678')
|
||||||
|
assert.equal(maskSecretForDisplay('AIzaSecret12345678'), 'AIza...5678')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => {
|
||||||
|
const apiKey = 'sk-secret-12345678'
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }),
|
||||||
|
'sk-...5678',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }),
|
||||||
|
'gpt-4o',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sanitizeProviderConfigValue drops secret-like poisoned values', () => {
|
||||||
|
const apiKey = 'sk-secret-12345678'
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
sanitizeProviderConfigValue(apiKey, { OPENAI_API_KEY: apiKey }),
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
sanitizeProviderConfigValue('gpt-4o', { OPENAI_API_KEY: apiKey }),
|
||||||
|
'gpt-4o',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('openai profiles ignore codex shell transport hints', () => {
|
test('openai profiles ignore codex shell transport hints', () => {
|
||||||
const env = buildOpenAIProfileEnv({
|
const env = buildOpenAIProfileEnv({
|
||||||
goal: 'balanced',
|
goal: 'balanced',
|
||||||
@@ -377,6 +491,40 @@ test('openai profiles ignore codex shell transport hints', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('openai profiles ignore poisoned shell model and base url values', () => {
|
||||||
|
const env = buildOpenAIProfileEnv({
|
||||||
|
goal: 'balanced',
|
||||||
|
apiKey: 'sk-live',
|
||||||
|
processEnv: {
|
||||||
|
OPENAI_BASE_URL: 'sk-live',
|
||||||
|
OPENAI_MODEL: 'sk-live',
|
||||||
|
OPENAI_API_KEY: 'sk-live',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(env, {
|
||||||
|
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
OPENAI_MODEL: 'gpt-4o',
|
||||||
|
OPENAI_API_KEY: 'sk-live',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('startup env ignores poisoned persisted openai model and base url', async () => {
|
||||||
|
const env = await buildStartupEnvFromProfile({
|
||||||
|
persisted: profile('openai', {
|
||||||
|
OPENAI_API_KEY: 'sk-live',
|
||||||
|
OPENAI_MODEL: 'sk-live',
|
||||||
|
OPENAI_BASE_URL: 'sk-live',
|
||||||
|
}),
|
||||||
|
processEnv: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1')
|
||||||
|
assert.equal(env.OPENAI_API_KEY, 'sk-live')
|
||||||
|
assert.equal(env.OPENAI_MODEL, 'gpt-4o')
|
||||||
|
assert.equal(env.OPENAI_BASE_URL, 'https://api.openai.com/v1')
|
||||||
|
})
|
||||||
|
|
||||||
test('auto profile falls back to openai when no viable ollama model exists', () => {
|
test('auto profile falls back to openai when no viable ollama model exists', () => {
|
||||||
assert.equal(selectAutoProfile(null), 'openai')
|
assert.equal(selectAutoProfile(null), 'openai')
|
||||||
assert.equal(selectAutoProfile('qwen2.5-coder:7b'), 'ollama')
|
assert.equal(selectAutoProfile('qwen2.5-coder:7b'), 'ollama')
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODEX_BASE_URL,
|
DEFAULT_CODEX_BASE_URL,
|
||||||
DEFAULT_OPENAI_BASE_URL,
|
DEFAULT_OPENAI_BASE_URL,
|
||||||
@@ -7,11 +9,40 @@ import {
|
|||||||
} from '../services/api/providerConfig.ts'
|
} from '../services/api/providerConfig.ts'
|
||||||
import {
|
import {
|
||||||
getGoalDefaultOpenAIModel,
|
getGoalDefaultOpenAIModel,
|
||||||
|
normalizeRecommendationGoal,
|
||||||
type RecommendationGoal,
|
type RecommendationGoal,
|
||||||
} from './providerRecommendation.ts'
|
} from './providerRecommendation.ts'
|
||||||
|
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
|
||||||
|
|
||||||
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
|
||||||
const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash'
|
export const DEFAULT_GEMINI_BASE_URL =
|
||||||
|
'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
|
export const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash'
|
||||||
|
|
||||||
|
const PROFILE_ENV_KEYS = [
|
||||||
|
'CLAUDE_CODE_USE_OPENAI',
|
||||||
|
'CLAUDE_CODE_USE_GEMINI',
|
||||||
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
|
'CLAUDE_CODE_USE_FOUNDRY',
|
||||||
|
'OPENAI_BASE_URL',
|
||||||
|
'OPENAI_MODEL',
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'CODEX_API_KEY',
|
||||||
|
'CHATGPT_ACCOUNT_ID',
|
||||||
|
'CODEX_ACCOUNT_ID',
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GEMINI_MODEL',
|
||||||
|
'GEMINI_BASE_URL',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SECRET_ENV_KEYS = [
|
||||||
|
'OPENAI_API_KEY',
|
||||||
|
'CODEX_API_KEY',
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'GOOGLE_API_KEY',
|
||||||
|
] as const
|
||||||
|
|
||||||
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini'
|
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini'
|
||||||
|
|
||||||
@@ -33,6 +64,35 @@ export type ProfileFile = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecretValueSource = Partial<
|
||||||
|
Pick<
|
||||||
|
NodeJS.ProcessEnv & ProfileEnv,
|
||||||
|
(typeof SECRET_ENV_KEYS)[number]
|
||||||
|
>
|
||||||
|
>
|
||||||
|
|
||||||
|
type ProfileFileLocation = {
|
||||||
|
cwd?: string
|
||||||
|
filePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProfileFilePath(options?: ProfileFileLocation): string {
|
||||||
|
if (options?.filePath) {
|
||||||
|
return options.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(options?.cwd ?? process.cwd(), PROFILE_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProviderProfile(value: unknown): value is ProviderProfile {
|
||||||
|
return (
|
||||||
|
value === 'openai' ||
|
||||||
|
value === 'ollama' ||
|
||||||
|
value === 'codex' ||
|
||||||
|
value === 'gemini'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeApiKey(
|
export function sanitizeApiKey(
|
||||||
key: string | null | undefined,
|
key: string | null | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
@@ -40,6 +100,95 @@ export function sanitizeApiKey(
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeSecretValue(value: string): boolean {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
|
||||||
|
if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('AIza')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSecretValues(
|
||||||
|
sources: Array<SecretValueSource | null | undefined>,
|
||||||
|
): string[] {
|
||||||
|
const values = new Set<string>()
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue
|
||||||
|
|
||||||
|
for (const key of SECRET_ENV_KEYS) {
|
||||||
|
const value = sanitizeApiKey(source[key])
|
||||||
|
if (value) {
|
||||||
|
values.add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...values]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskSecretForDisplay(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const sanitized = sanitizeApiKey(value)
|
||||||
|
if (!sanitized) return undefined
|
||||||
|
|
||||||
|
if (sanitized.length <= 8) {
|
||||||
|
return 'configured'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.startsWith('sk-')) {
|
||||||
|
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized.startsWith('AIza')) {
|
||||||
|
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactSecretValueForDisplay(
|
||||||
|
value: string | null | undefined,
|
||||||
|
...sources: Array<SecretValueSource | null | undefined>
|
||||||
|
): string | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
|
const secretValues = collectSecretValues(sources)
|
||||||
|
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
|
||||||
|
return maskSecretForDisplay(trimmed) ?? 'configured'
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeProviderConfigValue(
|
||||||
|
value: string | null | undefined,
|
||||||
|
...sources: Array<SecretValueSource | null | undefined>
|
||||||
|
): string | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
|
||||||
|
const secretValues = collectSecretValues(sources)
|
||||||
|
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOllamaProfileEnv(
|
export function buildOllamaProfileEnv(
|
||||||
model: string,
|
model: string,
|
||||||
options: {
|
options: {
|
||||||
@@ -71,11 +220,23 @@ export function buildGeminiProfileEnv(options: {
|
|||||||
|
|
||||||
const env: ProfileEnv = {
|
const env: ProfileEnv = {
|
||||||
GEMINI_MODEL:
|
GEMINI_MODEL:
|
||||||
options.model || processEnv.GEMINI_MODEL || DEFAULT_GEMINI_MODEL,
|
sanitizeProviderConfigValue(options.model, { GEMINI_API_KEY: key }, processEnv) ||
|
||||||
|
sanitizeProviderConfigValue(
|
||||||
|
processEnv.GEMINI_MODEL,
|
||||||
|
{ GEMINI_API_KEY: key },
|
||||||
|
processEnv,
|
||||||
|
) ||
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
GEMINI_API_KEY: key,
|
GEMINI_API_KEY: key,
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = options.baseUrl || processEnv.GEMINI_BASE_URL
|
const baseUrl =
|
||||||
|
sanitizeProviderConfigValue(options.baseUrl, { GEMINI_API_KEY: key }, processEnv) ||
|
||||||
|
sanitizeProviderConfigValue(
|
||||||
|
processEnv.GEMINI_BASE_URL,
|
||||||
|
{ GEMINI_API_KEY: key },
|
||||||
|
processEnv,
|
||||||
|
)
|
||||||
if (baseUrl) {
|
if (baseUrl) {
|
||||||
env.GEMINI_BASE_URL = baseUrl
|
env.GEMINI_BASE_URL = baseUrl
|
||||||
}
|
}
|
||||||
@@ -97,21 +258,39 @@ export function buildOpenAIProfileEnv(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultModel = getGoalDefaultOpenAIModel(options.goal)
|
const defaultModel = getGoalDefaultOpenAIModel(options.goal)
|
||||||
|
const shellOpenAIModel = sanitizeProviderConfigValue(
|
||||||
|
processEnv.OPENAI_MODEL,
|
||||||
|
{ OPENAI_API_KEY: key },
|
||||||
|
processEnv,
|
||||||
|
)
|
||||||
|
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
|
||||||
|
processEnv.OPENAI_BASE_URL,
|
||||||
|
{ OPENAI_API_KEY: key },
|
||||||
|
processEnv,
|
||||||
|
)
|
||||||
const shellOpenAIRequest = resolveProviderRequest({
|
const shellOpenAIRequest = resolveProviderRequest({
|
||||||
model: processEnv.OPENAI_MODEL,
|
model: shellOpenAIModel,
|
||||||
baseUrl: processEnv.OPENAI_BASE_URL,
|
baseUrl: shellOpenAIBaseUrl,
|
||||||
fallbackModel: defaultModel,
|
fallbackModel: defaultModel,
|
||||||
})
|
})
|
||||||
const useShellOpenAIConfig = shellOpenAIRequest.transport === 'chat_completions'
|
const useShellOpenAIConfig = shellOpenAIRequest.transport === 'chat_completions'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
OPENAI_BASE_URL:
|
OPENAI_BASE_URL:
|
||||||
options.baseUrl ||
|
sanitizeProviderConfigValue(
|
||||||
(useShellOpenAIConfig ? processEnv.OPENAI_BASE_URL : undefined) ||
|
options.baseUrl,
|
||||||
|
{ OPENAI_API_KEY: key },
|
||||||
|
processEnv,
|
||||||
|
) ||
|
||||||
|
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
|
||||||
DEFAULT_OPENAI_BASE_URL,
|
DEFAULT_OPENAI_BASE_URL,
|
||||||
OPENAI_MODEL:
|
OPENAI_MODEL:
|
||||||
options.model ||
|
sanitizeProviderConfigValue(
|
||||||
(useShellOpenAIConfig ? processEnv.OPENAI_MODEL : undefined) ||
|
options.model,
|
||||||
|
{ OPENAI_API_KEY: key },
|
||||||
|
processEnv,
|
||||||
|
) ||
|
||||||
|
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
||||||
defaultModel,
|
defaultModel,
|
||||||
OPENAI_API_KEY: key,
|
OPENAI_API_KEY: key,
|
||||||
}
|
}
|
||||||
@@ -158,6 +337,61 @@ export function createProfileFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadProfileFile(options?: ProfileFileLocation): ProfileFile | null {
|
||||||
|
const filePath = resolveProfileFilePath(options)
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<ProfileFile>
|
||||||
|
if (!isProviderProfile(parsed.profile) || !parsed.env || typeof parsed.env !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: parsed.profile,
|
||||||
|
env: parsed.env,
|
||||||
|
createdAt:
|
||||||
|
typeof parsed.createdAt === 'string'
|
||||||
|
? parsed.createdAt
|
||||||
|
: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfileFile(
|
||||||
|
profileFile: ProfileFile,
|
||||||
|
options?: ProfileFileLocation,
|
||||||
|
): string {
|
||||||
|
const filePath = resolveProfileFilePath(options)
|
||||||
|
writeFileSync(filePath, JSON.stringify(profileFile, null, 2), {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o600,
|
||||||
|
})
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProfileFile(options?: ProfileFileLocation): string {
|
||||||
|
const filePath = resolveProfileFilePath(options)
|
||||||
|
rmSync(filePath, { force: true })
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExplicitProviderSelection(
|
||||||
|
processEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined ||
|
||||||
|
processEnv.CLAUDE_CODE_USE_GEMINI !== undefined ||
|
||||||
|
processEnv.CLAUDE_CODE_USE_BEDROCK !== undefined ||
|
||||||
|
processEnv.CLAUDE_CODE_USE_VERTEX !== undefined ||
|
||||||
|
processEnv.CLAUDE_CODE_USE_FOUNDRY !== undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function selectAutoProfile(
|
export function selectAutoProfile(
|
||||||
recommendedOllamaModel: string | null,
|
recommendedOllamaModel: string | null,
|
||||||
): ProviderProfile {
|
): ProviderProfile {
|
||||||
@@ -177,6 +411,38 @@ export async function buildLaunchEnv(options: {
|
|||||||
options.persisted?.profile === options.profile
|
options.persisted?.profile === options.profile
|
||||||
? options.persisted.env ?? {}
|
? options.persisted.env ?? {}
|
||||||
: {}
|
: {}
|
||||||
|
const persistedOpenAIModel = sanitizeProviderConfigValue(
|
||||||
|
persistedEnv.OPENAI_MODEL,
|
||||||
|
persistedEnv,
|
||||||
|
)
|
||||||
|
const persistedOpenAIBaseUrl = sanitizeProviderConfigValue(
|
||||||
|
persistedEnv.OPENAI_BASE_URL,
|
||||||
|
persistedEnv,
|
||||||
|
)
|
||||||
|
const shellOpenAIModel = sanitizeProviderConfigValue(
|
||||||
|
processEnv.OPENAI_MODEL,
|
||||||
|
processEnv,
|
||||||
|
)
|
||||||
|
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
|
||||||
|
processEnv.OPENAI_BASE_URL,
|
||||||
|
processEnv,
|
||||||
|
)
|
||||||
|
const persistedGeminiModel = sanitizeProviderConfigValue(
|
||||||
|
persistedEnv.GEMINI_MODEL,
|
||||||
|
persistedEnv,
|
||||||
|
)
|
||||||
|
const persistedGeminiBaseUrl = sanitizeProviderConfigValue(
|
||||||
|
persistedEnv.GEMINI_BASE_URL,
|
||||||
|
persistedEnv,
|
||||||
|
)
|
||||||
|
const shellGeminiModel = sanitizeProviderConfigValue(
|
||||||
|
processEnv.GEMINI_MODEL,
|
||||||
|
processEnv,
|
||||||
|
)
|
||||||
|
const shellGeminiBaseUrl = sanitizeProviderConfigValue(
|
||||||
|
processEnv.GEMINI_BASE_URL,
|
||||||
|
processEnv,
|
||||||
|
)
|
||||||
|
|
||||||
const shellGeminiKey = sanitizeApiKey(
|
const shellGeminiKey = sanitizeApiKey(
|
||||||
processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY,
|
processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY,
|
||||||
@@ -192,12 +458,12 @@ export async function buildLaunchEnv(options: {
|
|||||||
delete env.CLAUDE_CODE_USE_OPENAI
|
delete env.CLAUDE_CODE_USE_OPENAI
|
||||||
|
|
||||||
env.GEMINI_MODEL =
|
env.GEMINI_MODEL =
|
||||||
processEnv.GEMINI_MODEL ||
|
shellGeminiModel ||
|
||||||
persistedEnv.GEMINI_MODEL ||
|
persistedGeminiModel ||
|
||||||
DEFAULT_GEMINI_MODEL
|
DEFAULT_GEMINI_MODEL
|
||||||
env.GEMINI_BASE_URL =
|
env.GEMINI_BASE_URL =
|
||||||
processEnv.GEMINI_BASE_URL ||
|
shellGeminiBaseUrl ||
|
||||||
persistedEnv.GEMINI_BASE_URL ||
|
persistedGeminiBaseUrl ||
|
||||||
DEFAULT_GEMINI_BASE_URL
|
DEFAULT_GEMINI_BASE_URL
|
||||||
|
|
||||||
const geminiKey = shellGeminiKey || persistedGeminiKey
|
const geminiKey = shellGeminiKey || persistedGeminiKey
|
||||||
@@ -235,9 +501,9 @@ export async function buildLaunchEnv(options: {
|
|||||||
const resolveOllamaModel =
|
const resolveOllamaModel =
|
||||||
options.resolveOllamaDefaultModel ?? (async () => 'llama3.1:8b')
|
options.resolveOllamaDefaultModel ?? (async () => 'llama3.1:8b')
|
||||||
|
|
||||||
env.OPENAI_BASE_URL = persistedEnv.OPENAI_BASE_URL || getOllamaBaseUrl()
|
env.OPENAI_BASE_URL = persistedOpenAIBaseUrl || getOllamaBaseUrl()
|
||||||
env.OPENAI_MODEL =
|
env.OPENAI_MODEL =
|
||||||
persistedEnv.OPENAI_MODEL ||
|
persistedOpenAIModel ||
|
||||||
(await resolveOllamaModel(options.goal))
|
(await resolveOllamaModel(options.goal))
|
||||||
|
|
||||||
delete env.OPENAI_API_KEY
|
delete env.OPENAI_API_KEY
|
||||||
@@ -250,10 +516,10 @@ export async function buildLaunchEnv(options: {
|
|||||||
|
|
||||||
if (options.profile === 'codex') {
|
if (options.profile === 'codex') {
|
||||||
env.OPENAI_BASE_URL =
|
env.OPENAI_BASE_URL =
|
||||||
persistedEnv.OPENAI_BASE_URL && isCodexBaseUrl(persistedEnv.OPENAI_BASE_URL)
|
persistedOpenAIBaseUrl && isCodexBaseUrl(persistedOpenAIBaseUrl)
|
||||||
? persistedEnv.OPENAI_BASE_URL
|
? persistedOpenAIBaseUrl
|
||||||
: DEFAULT_CODEX_BASE_URL
|
: DEFAULT_CODEX_BASE_URL
|
||||||
env.OPENAI_MODEL = persistedEnv.OPENAI_MODEL || 'codexplan'
|
env.OPENAI_MODEL = persistedOpenAIModel || 'codexplan'
|
||||||
delete env.OPENAI_API_KEY
|
delete env.OPENAI_API_KEY
|
||||||
|
|
||||||
const codexKey =
|
const codexKey =
|
||||||
@@ -284,27 +550,27 @@ export async function buildLaunchEnv(options: {
|
|||||||
|
|
||||||
const defaultOpenAIModel = getGoalDefaultOpenAIModel(options.goal)
|
const defaultOpenAIModel = getGoalDefaultOpenAIModel(options.goal)
|
||||||
const shellOpenAIRequest = resolveProviderRequest({
|
const shellOpenAIRequest = resolveProviderRequest({
|
||||||
model: processEnv.OPENAI_MODEL,
|
model: shellOpenAIModel,
|
||||||
baseUrl: processEnv.OPENAI_BASE_URL,
|
baseUrl: shellOpenAIBaseUrl,
|
||||||
fallbackModel: defaultOpenAIModel,
|
fallbackModel: defaultOpenAIModel,
|
||||||
})
|
})
|
||||||
const persistedOpenAIRequest = resolveProviderRequest({
|
const persistedOpenAIRequest = resolveProviderRequest({
|
||||||
model: persistedEnv.OPENAI_MODEL,
|
model: persistedOpenAIModel,
|
||||||
baseUrl: persistedEnv.OPENAI_BASE_URL,
|
baseUrl: persistedOpenAIBaseUrl,
|
||||||
fallbackModel: defaultOpenAIModel,
|
fallbackModel: defaultOpenAIModel,
|
||||||
})
|
})
|
||||||
const useShellOpenAIConfig = shellOpenAIRequest.transport === 'chat_completions'
|
const useShellOpenAIConfig = shellOpenAIRequest.transport === 'chat_completions'
|
||||||
const usePersistedOpenAIConfig =
|
const usePersistedOpenAIConfig =
|
||||||
(!persistedEnv.OPENAI_MODEL && !persistedEnv.OPENAI_BASE_URL) ||
|
(!persistedOpenAIModel && !persistedOpenAIBaseUrl) ||
|
||||||
persistedOpenAIRequest.transport === 'chat_completions'
|
persistedOpenAIRequest.transport === 'chat_completions'
|
||||||
|
|
||||||
env.OPENAI_BASE_URL =
|
env.OPENAI_BASE_URL =
|
||||||
(useShellOpenAIConfig ? processEnv.OPENAI_BASE_URL : undefined) ||
|
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
|
||||||
(usePersistedOpenAIConfig ? persistedEnv.OPENAI_BASE_URL : undefined) ||
|
(usePersistedOpenAIConfig ? persistedOpenAIBaseUrl : undefined) ||
|
||||||
DEFAULT_OPENAI_BASE_URL
|
DEFAULT_OPENAI_BASE_URL
|
||||||
env.OPENAI_MODEL =
|
env.OPENAI_MODEL =
|
||||||
(useShellOpenAIConfig ? processEnv.OPENAI_MODEL : undefined) ||
|
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
||||||
(usePersistedOpenAIConfig ? persistedEnv.OPENAI_MODEL : undefined) ||
|
(usePersistedOpenAIConfig ? persistedOpenAIModel : undefined) ||
|
||||||
defaultOpenAIModel
|
defaultOpenAIModel
|
||||||
env.OPENAI_API_KEY = processEnv.OPENAI_API_KEY || persistedEnv.OPENAI_API_KEY
|
env.OPENAI_API_KEY = processEnv.OPENAI_API_KEY || persistedEnv.OPENAI_API_KEY
|
||||||
delete env.CODEX_API_KEY
|
delete env.CODEX_API_KEY
|
||||||
@@ -312,3 +578,44 @@ export async function buildLaunchEnv(options: {
|
|||||||
delete env.CODEX_ACCOUNT_ID
|
delete env.CODEX_ACCOUNT_ID
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildStartupEnvFromProfile(options?: {
|
||||||
|
persisted?: ProfileFile | null
|
||||||
|
goal?: RecommendationGoal
|
||||||
|
processEnv?: NodeJS.ProcessEnv
|
||||||
|
getOllamaChatBaseUrl?: (baseUrl?: string) => string
|
||||||
|
resolveOllamaDefaultModel?: (goal: RecommendationGoal) => Promise<string>
|
||||||
|
}): Promise<NodeJS.ProcessEnv> {
|
||||||
|
const processEnv = options?.processEnv ?? process.env
|
||||||
|
if (hasExplicitProviderSelection(processEnv)) {
|
||||||
|
return processEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = options?.persisted ?? loadProfileFile()
|
||||||
|
if (!persisted) {
|
||||||
|
return processEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildLaunchEnv({
|
||||||
|
profile: persisted.profile,
|
||||||
|
persisted,
|
||||||
|
goal:
|
||||||
|
options?.goal ??
|
||||||
|
normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
|
||||||
|
processEnv,
|
||||||
|
getOllamaChatBaseUrl:
|
||||||
|
options?.getOllamaChatBaseUrl ?? getOllamaChatBaseUrl,
|
||||||
|
resolveOllamaDefaultModel: options?.resolveOllamaDefaultModel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProfileEnvToProcessEnv(
|
||||||
|
targetEnv: NodeJS.ProcessEnv,
|
||||||
|
nextEnv: NodeJS.ProcessEnv,
|
||||||
|
): void {
|
||||||
|
for (const key of PROFILE_ENV_KEYS) {
|
||||||
|
delete targetEnv[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(targetEnv, nextEnv)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user