Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# -----------------------------------------------------------------------------
# CLAUDE_CODE_USE_OPENAI=1
# OPENAI_API_KEY=sk-your-key-here
# Optional: rotate across multiple OpenAI-compatible API keys on auth/quota/rate-limit failures.
# OPENAI_API_KEYS=sk-key-a,sk-key-b
# OPENAI_MODEL=gpt-4o
# For DeepSeek, set:
# OPENAI_BASE_URL=https://api.deepseek.com/v1
Expand Down
5 changes: 3 additions & 2 deletions docs/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,8 @@ The **OpenClaude VS Code extension** can store the key in Secret Storage and set
| Variable | Required | Description |
|----------|----------|-------------|
| `CLAUDE_CODE_USE_OPENAI` | OpenAI-compatible only | Set to `1` to enable the OpenAI-compatible provider path |
| `OPENAI_API_KEY` | OpenAI-compatible cloud routes* | Your API key (`*` not needed for local models like Ollama, LM Studio, Atomic Chat, or other local OpenAI-compatible proxies) |
| `OPENAI_API_KEYS` | One of `OPENAI_API_KEYS` or `OPENAI_API_KEY` for non-local OpenAI-compatible cloud routes* | Comma-separated OpenAI-compatible API key pool. Takes precedence over `OPENAI_API_KEY` and rotates to the next key on auth, quota, or rate-limit failures (`*` not needed for local models like Ollama, LM Studio, Atomic Chat, or other local OpenAI-compatible proxies). |
| `OPENAI_API_KEY` | Required only when `OPENAI_API_KEYS` is unset or empty for non-local OpenAI-compatible cloud routes* | Your API key (`*` not needed for local models like Ollama, LM Studio, Atomic Chat, or other local OpenAI-compatible proxies). A comma-separated list also enables key rotation. |
| `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` |
| `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` |
| `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` |
Expand Down Expand Up @@ -432,7 +433,7 @@ bun run dev:profile
# codex profile (uses CODEX_API_KEY or ~/.codex/auth.json)
bun run dev:codex

# OpenAI profile (uses the saved OpenAI profile, or OPENAI_API_KEY from your shell)
# OpenAI profile (uses the saved OpenAI profile, or OPENAI_API_KEYS / OPENAI_API_KEY from your shell)
bun run dev:openai

# Gemini profile (uses the saved Gemini profile, or GEMINI_API_KEY / GOOGLE_API_KEY from your shell)
Expand Down
6 changes: 4 additions & 2 deletions scripts/provider-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,14 @@ async function main(): Promise<void> {
goal,
model: argModel || null,
baseUrl: argBaseUrl || null,
apiKey: argApiKey || process.env.OPENAI_API_KEY || null,
apiKey: argApiKey || null,
processEnv: process.env,
})

if (!builtEnv) {
console.error('OpenAI profile requires a real API key. Use --api-key or set OPENAI_API_KEY.')
console.error(
'OpenAI profile requires real credential(s). Use --api-key or set OPENAI_API_KEYS or OPENAI_API_KEY.',
)
process.exit(1)
}

Expand Down
22 changes: 22 additions & 0 deletions scripts/provider-launch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { hasUsableOpenAILaunchCredential } from './provider-launch.ts'

describe('provider-launch OpenAI credential validation', () => {
test('accepts valid OPENAI_API_KEYS before placeholder OPENAI_API_KEY fallback', () => {
expect(
hasUsableOpenAILaunchCredential({
OPENAI_API_KEYS: 'sk-openai-a,sk-openai-b',
OPENAI_API_KEY: 'SUA_CHAVE',
} as NodeJS.ProcessEnv),
).toBe(true)
})

test('rejects placeholder OPENAI_API_KEYS before singular fallback', () => {
expect(
hasUsableOpenAILaunchCredential({
OPENAI_API_KEYS: 'sk-openai-a,SUA_CHAVE',
OPENAI_API_KEY: 'sk-openai-single',
} as NodeJS.ProcessEnv),
).toBe(false)
})
})
17 changes: 14 additions & 3 deletions scripts/provider-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
buildLaunchEnv,
loadProfileFile,
resolveOpenAICredentialEnvState,
selectAutoProfile,
type ProfileFile,
type ProviderProfile,
Expand Down Expand Up @@ -152,6 +153,12 @@ function hasUsableGeminiLaunchAuth(env: NodeJS.ProcessEnv): boolean {
)
}

export function hasUsableOpenAILaunchCredential(
env: NodeJS.ProcessEnv,
): boolean {
return resolveOpenAICredentialEnvState(env).configured
}

async function main(): Promise<void> {
const options = parseLaunchOptions(process.argv.slice(2))
const requestedProfile = options.requestedProfile
Expand Down Expand Up @@ -227,8 +234,10 @@ async function main(): Promise<void> {
process.exit(1)
}

if (profile === 'openai' && (!env.OPENAI_API_KEY || env.OPENAI_API_KEY === 'SUA_CHAVE')) {
console.error('OPENAI_API_KEY is required for openai profile and cannot be SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key <key>')
if (profile === 'openai' && !hasUsableOpenAILaunchCredential(env)) {
console.error(
'OPENAI_API_KEYS or OPENAI_API_KEY is required for openai profile and cannot include SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key <key>',
)
process.exit(1)
}

Expand Down Expand Up @@ -265,6 +274,8 @@ async function main(): Promise<void> {
process.exit(devCode)
}

await main()
if (import.meta.main) {
await main()
}

export {}
96 changes: 96 additions & 0 deletions scripts/provider-recommend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, test } from 'bun:test'
import { getOpenAIConfigurationState } from './provider-recommend.ts'

function envWithOpenAI(
patch: Record<string, string | undefined>,
): NodeJS.ProcessEnv {
const env = { ...process.env, ...patch }
for (const key of ['OPENAI_API_KEYS', 'OPENAI_API_KEY']) {
if (patch[key] === undefined) {
delete env[key]
}
}
return env
}

async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
return await new Response(stream).text()
}

describe('provider-recommend OpenAI credential detection', () => {
test('detects valid pooled OpenAI credentials', () => {
expect(
getOpenAIConfigurationState(
envWithOpenAI({
OPENAI_API_KEYS: 'key-a,key-b',
OPENAI_API_KEY: undefined,
}),
),
).toEqual({ configured: true, invalid: false })
})

test('ignores placeholder OPENAI_API_KEY when OPENAI_API_KEYS is usable', () => {
expect(
getOpenAIConfigurationState(
envWithOpenAI({
OPENAI_API_KEYS: 'key-a,key-b',
OPENAI_API_KEY: 'SUA_CHAVE',
}),
),
).toEqual({ configured: true, invalid: false })
})

test('distinguishes placeholder pools from unset credentials', () => {
expect(
getOpenAIConfigurationState(
envWithOpenAI({
OPENAI_API_KEYS: 'key-a,SUA_CHAVE',
OPENAI_API_KEY: undefined,
}),
),
).toEqual({ configured: false, invalid: true })

expect(
getOpenAIConfigurationState(
envWithOpenAI({
OPENAI_API_KEYS: undefined,
OPENAI_API_KEY: undefined,
}),
),
).toEqual({ configured: false, invalid: false })
})

test('profile:recommend openai path runs with pooled credentials', async () => {
const env = envWithOpenAI({
OPENAI_API_KEYS: 'key-a,key-b',
OPENAI_API_KEY: undefined,
NO_COLOR: '1',
})
const proc = Bun.spawn(
[
process.execPath,
'run',
'scripts/provider-recommend.ts',
'--provider',
'openai',
'--json',
],
{
cwd: process.cwd(),
env,
stdout: 'pipe',
stderr: 'pipe',
},
)

const [stdout, stderr, exitCode] = await Promise.all([
readStream(proc.stdout),
readStream(proc.stderr),
proc.exited,
])

expect(stderr).toBe('')
expect(exitCode).toBe(0)
expect(JSON.parse(stdout).openAIConfigured).toBe(true)
})
})
26 changes: 19 additions & 7 deletions scripts/provider-recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
buildOpenAIProfileEnv,
createProfileFile,
saveProfileFile,
sanitizeApiKey,
resolveOpenAICredentialEnvState,
type ProfileFile,
type ProviderProfile,
} from '../src/utils/providerProfile.ts'
Expand All @@ -35,6 +35,13 @@ type CliOptions = {
baseUrl: string | null
}

export function getOpenAIConfigurationState(
env: NodeJS.ProcessEnv = process.env,
): { configured: boolean; invalid: boolean } {
const { configured, invalid } = resolveOpenAICredentialEnvState(env)
return { configured, invalid }
}

function parseOptions(argv: string[]): CliOptions {
const options: CliOptions = {
apply: false,
Expand Down Expand Up @@ -140,12 +147,11 @@ async function maybeApplyProfile(
env = buildOpenAIProfileEnv({
goal,
model: model || getGoalDefaultOpenAIModel(goal),
apiKey: process.env.OPENAI_API_KEY,
processEnv: process.env,
})

if (!env) {
console.error('Cannot apply an OpenAI profile without OPENAI_API_KEY.')
console.error('Cannot apply an OpenAI profile without OPENAI_API_KEYS or OPENAI_API_KEY.')
return false
}
}
Expand Down Expand Up @@ -186,7 +192,8 @@ async function main(): Promise<void> {
}))

const recommendedOllama = selectRecommendedOllamaModel(rankedModels)
const openAIConfigured = Boolean(sanitizeApiKey(process.env.OPENAI_API_KEY))
const openAIConfiguration = getOpenAIConfigurationState(process.env)
const openAIConfigured = openAIConfiguration.configured

let recommendedProfile: ProviderProfile
let recommendedModel: string
Expand Down Expand Up @@ -252,14 +259,19 @@ async function main(): Promise<void> {

if (!recommendedOllama && !openAIConfigured) {
console.log(
'\nNo local Ollama model was detected and OPENAI_API_KEY is unset.',
`
No local Ollama model was detected and OPENAI_API_KEYS / OPENAI_API_KEY ${
openAIConfiguration.invalid ? 'are invalid' : 'are unset'
}.`,
)
console.log(
'Next steps: `ollama pull qwen2.5-coder:7b` or set OPENAI_API_KEY.',
'Next steps: `ollama pull qwen2.5-coder:7b` or set valid OPENAI_API_KEYS or OPENAI_API_KEY.',
)
}
}

await main()
if (import.meta.main) {
await main()
}

export {}
71 changes: 68 additions & 3 deletions scripts/system-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const ENV_KEYS = [
'MISTRAL_MODEL',
'OPENAI_MODEL',
'OPENAI_BASE_URL',
'OPENAI_API_KEYS',
'OPENAI_API_KEY',
'OPENGATEWAY_API_KEY',
'GITHUB_TOKEN',
Expand Down Expand Up @@ -183,15 +184,79 @@ describe('system-check provider diagnostics', () => {

const results = checkOpenAIEnv()
const summary = serializeSafeEnvSummary()
const credentialResult = results.find(result => result.label === 'OPENAI_API_KEY')
const credentialResult = results.find(
result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY',
)

expect(credentialResult).toEqual({
ok: false,
label: 'OPENAI_API_KEY',
detail: 'Missing key for non-local provider URL. Set OPENAI_API_KEY.',
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail:
'Missing key for non-local provider URL. Set OPENAI_API_KEYS or OPENAI_API_KEY.',
})
expect(summary.PROVIDER_API_KEY_SET).toBe(false)
})

test('falls back to OPENAI_API_KEY when OPENAI_API_KEYS is delimiter-only', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.OPENAI_API_KEYS = ', ,'
process.env.OPENAI_API_KEY = 'sk-openai-single'

const results = checkOpenAIEnv()
const summary = serializeSafeEnvSummary()
const credentialResult = results.find(
result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY',
)

expect(credentialResult).toEqual({
ok: true,
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail: 'Configured.',
})
expect(summary.PROVIDER_API_KEY_SET).toBe(true)
})

test('accepts valid OPENAI_API_KEYS before placeholder OPENAI_API_KEY fallback', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.OPENAI_API_KEYS = 'sk-openai-a,sk-openai-b'
process.env.OPENAI_API_KEY = 'SUA_CHAVE'

const results = checkOpenAIEnv()
const summary = serializeSafeEnvSummary()
const credentialResult = results.find(
result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY',
)

expect(credentialResult).toEqual({
ok: true,
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail: 'Configured.',
})
expect(summary.PROVIDER_API_KEY_SET).toBe(true)
})

test('rejects placeholder values inside OPENAI_API_KEYS pools', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
process.env.OPENAI_MODEL = 'gpt-4o'
process.env.OPENAI_API_KEYS = 'sk-openai-a,SUA_CHAVE'
delete process.env.OPENAI_API_KEY

const results = checkOpenAIEnv()
const credentialResult = results.find(
result => result.label === 'OPENAI_API_KEYS or OPENAI_API_KEY',
)

expect(credentialResult).toEqual({
ok: false,
label: 'OPENAI_API_KEYS or OPENAI_API_KEY',
detail: 'Placeholder value detected: SUA_CHAVE.',
})
})
})

describe('checkNodeVersion', () => {
Expand Down
Loading