Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/services/api/providerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions src/utils/providerValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 10 additions & 3 deletions src/utils/providerValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '../integrations/routeMetadata.js'
import {
getGithubEndpointType,
isLikelyOllamaEndpoint,
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
Expand Down Expand Up @@ -256,7 +257,8 @@ function getCredentialEnvValidationError(
if (
validation.allowLocalBaseUrlWithoutCredential &&
request &&
isLocalProviderUrl(request.baseUrl)
(isLocalProviderUrl(request.baseUrl) ||
isLikelyOllamaEndpoint(request.baseUrl))
) {
return null
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
}

Expand Down
Loading