Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
40 changes: 40 additions & 0 deletions src/utils/providerValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_AUTH_HEADER: process.env.OPENAI_AUTH_HEADER,
OPENAI_AUTH_HEADER_VALUE: process.env.OPENAI_AUTH_HEADER_VALUE,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
Expand All @@ -28,6 +30,8 @@ function restoreEnv(key: string, value: string | undefined): void {
afterEach(() => {
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER)
restoreEnv('OPENAI_AUTH_HEADER_VALUE', originalEnv.OPENAI_AUTH_HEADER_VALUE)
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
Expand Down Expand Up @@ -97,6 +101,42 @@ test('openai missing key error includes recovery guidance and config locations',
expect(message).toContain('.openclaude-profile.json')
})

test('accepts custom OpenAI-compatible auth header value as provider credential', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.example.test/v1'
process.env.OPENAI_AUTH_HEADER = 'api-key'
process.env.OPENAI_AUTH_SCHEME = 'raw'
process.env.OPENAI_AUTH_HEADER_VALUE = 'custom-header-secret'
delete process.env.OPENAI_API_KEY

await expect(getProviderValidationError(process.env)).resolves.toBeNull()
})

test('requires custom OpenAI-compatible auth header value when header is set', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.example.test/v1'
process.env.OPENAI_AUTH_HEADER = 'api-key'
delete process.env.OPENAI_AUTH_HEADER_VALUE
delete process.env.OPENAI_API_KEY

await expect(getProviderValidationError(process.env)).resolves.toBe(
'OPENAI_AUTH_HEADER_VALUE is required when OPENAI_AUTH_HEADER is set.',
)
})

test('does not accept a custom OpenAI-compatible auth header value without a header name', async () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://api.example.test/v1'
process.env.OPENAI_AUTH_HEADER_VALUE = 'custom-header-secret'
delete process.env.OPENAI_AUTH_HEADER
delete process.env.OPENAI_API_KEY

const message = await getProviderValidationError(process.env)
expect(message).toContain(
'OPENAI_API_KEY is required when CLAUDE_CODE_USE_OPENAI=1 and OPENAI_BASE_URL is not local.',
)
})

test('startup provider validation allows interactive recovery', () => {
expect(
shouldExitForStartupProviderValidationError({
Expand Down
13 changes: 12 additions & 1 deletion src/utils/providerValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,18 @@ export async function getProviderValidationError(
return null
}

if (!env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) {
const hasOpenAIAuthHeader = Boolean(env.OPENAI_AUTH_HEADER?.trim())
const hasOpenAIAuthHeaderValue = Boolean(env.OPENAI_AUTH_HEADER_VALUE?.trim())
if (hasOpenAIAuthHeader && !hasOpenAIAuthHeaderValue) {
return 'OPENAI_AUTH_HEADER_VALUE is required when OPENAI_AUTH_HEADER is set.'
}

const hasOpenAIAuthCredential = Boolean(
env.OPENAI_API_KEY?.trim() ||
(hasOpenAIAuthHeader && hasOpenAIAuthHeaderValue),
)

if (!hasOpenAIAuthCredential && !isLocalProviderUrl(request.baseUrl)) {
const hasGithubToken = !!(env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim())
if (useGithub && hasGithubToken) {
return null
Expand Down
1 change: 0 additions & 1 deletion vscode-extension/openclaude-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"Other"
],
"activationEvents": [
"onStartupFinished",
"onCommand:openclaude.start",
"onCommand:openclaude.startInWorkspaceRoot",
"onCommand:openclaude.openDocs",
Expand Down
64 changes: 62 additions & 2 deletions vscode-extension/openclaude-vscode/src/chat/chatProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ const crypto = require('crypto');
const { ProcessManager } = require('./processManager');
const { toViewModel } = require('./messageParser');
const { renderChatHtml } = require('./chatRenderer');
const { SLASH_COMMANDS } = require('./slashCommands');
const {
buildProviderManagerState,
clearProviderProfile,
deleteProviderProfile,
saveProviderProfile,
setActiveProviderProfile,
} = require('./providerManager');
const { isAssistantMessage, isPartialMessage, isStreamEvent,
isContentBlockDelta, isContentBlockStart, isMessageStart,
isResultMessage, isControlRequest, isToolProgressMessage,
Expand Down Expand Up @@ -481,7 +489,7 @@ class OpenClaudeChatViewProvider {

_getHtml() {
const nonce = crypto.randomBytes(16).toString('hex');
return renderChatHtml({ nonce, platform: process.platform });
return renderChatHtml({ nonce, platform: process.platform, slashCommands: SLASH_COMMANDS });
}

_attachMessageHandler(webview) {
Expand All @@ -490,6 +498,32 @@ class OpenClaudeChatViewProvider {
case 'send_message':
this._chatController.sendMessage(msg.text);
break;
case 'open_provider_manager':
webview.postMessage({ type: 'provider_manager_state', state: await buildProviderManagerState() });
break;
case 'request_provider_state':
webview.postMessage({ type: 'provider_manager_state', state: await buildProviderManagerState() });
break;
case 'save_provider_profile':
try {
webview.postMessage({ type: 'provider_manager_state', state: await saveProviderProfile(msg.form || {}) });
webview.postMessage({ type: 'status', content: 'Provider profile saved and activated. Start a new chat session to use it.' });
} catch (err) {
webview.postMessage({ type: 'provider_manager_error', message: err.message || String(err) });
}
break;
case 'set_active_provider_profile':
webview.postMessage({ type: 'provider_manager_state', state: await setActiveProviderProfile(msg.profileId) });
webview.postMessage({ type: 'status', content: 'Provider profile activated. Start a new chat session to use it.' });
break;
case 'delete_provider_profile':
webview.postMessage({ type: 'provider_manager_state', state: await deleteProviderProfile(msg.profileId) });
webview.postMessage({ type: 'status', content: 'Provider profile deleted.' });
break;
case 'clear_provider_profile':
webview.postMessage({ type: 'provider_manager_state', state: await clearProviderProfile() });
webview.postMessage({ type: 'status', content: 'Startup provider profile cleared.' });
break;
case 'abort':
this._chatController.abort();
break;
Expand Down Expand Up @@ -584,7 +618,7 @@ class OpenClaudeChatPanelManager {
});

const nonce = crypto.randomBytes(16).toString('hex');
webview.html = renderChatHtml({ nonce, platform: process.platform });
webview.html = renderChatHtml({ nonce, platform: process.platform, slashCommands: SLASH_COMMANDS });
this._attachMessageHandler(webview);

const messages = this._chatController.getMessages();
Expand All @@ -599,6 +633,32 @@ class OpenClaudeChatPanelManager {
case 'send_message':
this._chatController.sendMessage(msg.text);
break;
case 'open_provider_manager':
webview.postMessage({ type: 'provider_manager_state', state: await buildProviderManagerState() });
break;
case 'request_provider_state':
webview.postMessage({ type: 'provider_manager_state', state: await buildProviderManagerState() });
break;
case 'save_provider_profile':
try {
webview.postMessage({ type: 'provider_manager_state', state: await saveProviderProfile(msg.form || {}) });
webview.postMessage({ type: 'status', content: 'Provider profile saved and activated. Start a new chat session to use it.' });
} catch (err) {
webview.postMessage({ type: 'provider_manager_error', message: err.message || String(err) });
}
break;
case 'set_active_provider_profile':
webview.postMessage({ type: 'provider_manager_state', state: await setActiveProviderProfile(msg.profileId) });
webview.postMessage({ type: 'status', content: 'Provider profile activated. Start a new chat session to use it.' });
break;
case 'delete_provider_profile':
webview.postMessage({ type: 'provider_manager_state', state: await deleteProviderProfile(msg.profileId) });
webview.postMessage({ type: 'status', content: 'Provider profile deleted.' });
break;
case 'clear_provider_profile':
webview.postMessage({ type: 'provider_manager_state', state: await clearProviderProfile() });
webview.postMessage({ type: 'status', content: 'Startup provider profile cleared.' });
break;
case 'abort':
this._chatController.abort();
break;
Expand Down
Loading
Loading