diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 694607c78..5f1e9b867 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -374,7 +374,8 @@ function normalizePathWithV1(pathname: string): string { return `${trimmed}/v1` } -function isLikelyOllamaEndpoint(baseUrl: string): boolean { +export function isLikelyOllamaEndpoint(baseUrl: string | undefined): boolean { + if (!baseUrl) return false try { const parsed = new URL(baseUrl) const hostname = parsed.hostname.toLowerCase() diff --git a/src/utils/providerValidation.test.ts b/src/utils/providerValidation.test.ts index ba149d388..bcf22f49c 100644 --- a/src/utils/providerValidation.test.ts +++ b/src/utils/providerValidation.test.ts @@ -253,6 +253,41 @@ test('github validation is skipped when openai mode is also active', async () => ) }) +test('remote Ollama by hostname does not require OPENAI_API_KEY (#369)', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://my-ollama-server.example.com:11434/v1' + delete process.env.OPENAI_API_KEY + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('remote Ollama on default port without API key is allowed (#369)', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'http://203.0.113.5:11434/v1' + delete process.env.OPENAI_API_KEY + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('remote Ollama identified by "ollama" in hostname is allowed without key (#369)', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://ollama.corp.example.com/v1' + delete process.env.OPENAI_API_KEY + + await expect(getProviderValidationError(process.env)).resolves.toBeNull() +}) + +test('non-Ollama remote provider still requires OPENAI_API_KEY', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' + 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({ diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 831cfda83..5e2f43f3c 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -20,6 +20,7 @@ import { } from '../integrations/routeMetadata.js' import { getGithubEndpointType, + isLikelyOllamaEndpoint, isLocalProviderUrl, resolveCodexApiCredentials, resolveProviderRequest, @@ -256,7 +257,8 @@ function getCredentialEnvValidationError( if ( validation.allowLocalBaseUrlWithoutCredential && request && - isLocalProviderUrl(request.baseUrl) + (isLocalProviderUrl(request.baseUrl) || + isLikelyOllamaEndpoint(request.baseUrl)) ) { return null } @@ -453,7 +455,8 @@ export async function getProviderValidationError( validationTarget.kind === 'vendor' && validationTarget.descriptor.id === 'openai' && !env.OPENAI_API_KEY && - !isLocalProviderUrl(request.baseUrl) + !isLocalProviderUrl(request.baseUrl) && + !isLikelyOllamaEndpoint(request.baseUrl) ) { return getOpenAIMissingKeyMessage() } @@ -469,7 +472,11 @@ export async function getProviderValidationError( return genericRouteValidation.error } - if (!env.OPENAI_API_KEY && !isLocalProviderUrl(request.baseUrl)) { + if ( + !env.OPENAI_API_KEY && + !isLocalProviderUrl(request.baseUrl) && + !isLikelyOllamaEndpoint(request.baseUrl) + ) { return getOpenAIMissingKeyMessage() }