feat(api): improve local provider reliability with readiness and self-healing (#738)

* feat(api): classify openai-compatible provider failures

* Update src/services/api/providerConfig.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(api): harden openai-compatible diagnostics and env fallback

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix openaiShim duplicate requests and diagnostics

* remove unused url from http failure classifier

* dedupe env diagnostic warnings

* Remove hardcoded URLs from OpenAI error tests

Removed hardcoded URLs from network failure classification tests.

* Update providerConfig.envDiagnostics.test.ts

* fix(openai-shim): return successful responses and restore localhost classifier tests

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(provider): add truthful local generation readiness checks

Implement Phase 2 provider readiness behavior by adding structured Ollama generation probes, wiring setup flows to readiness states, extending system-check with generation readiness output, and updating focused tests.

* feat(api): add local self-healing fallback retries

Implement Phase 3 self-healing behavior for local OpenAI-compatible providers: retry base URL fallbacks for localhost resolution and endpoint mismatches, plus capability-gated toolless retry for tool-incompatible local models; include diagnostics and focused tests.

* fix(api): address review blockers for local provider reliability

* Update src/utils/providerDiscovery.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: harden readiness probes and cross-platform test stability

* fix: refresh toolless retry payload and stabilize osc clipboard test

* fix: harden Ollama readiness parsing and redact provider URLs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
nehan
2026-04-20 12:24:02 +04:00
committed by GitHub
parent b09972f223
commit 4cb963e660
22 changed files with 1452 additions and 208 deletions

View File

@@ -320,10 +320,7 @@ export function classifyOpenAIHttpFailure(options: {
}
}
if (
(options.status >= 200 && options.status < 300 && isMalformedProviderResponse(body)) ||
(options.status >= 400 && isMalformedProviderResponse(body))
) {
if (options.status >= 400 && isMalformedProviderResponse(body)) {
return {
source: 'http',
category: 'malformed_provider_response',

View File

@@ -117,3 +117,170 @@ test('redacts credentials in transport diagnostic URL logs', async () => {
expect(logLine).not.toContain('user:supersecret')
expect(logLine).not.toContain('supersecret@')
})
test('logs self-heal localhost fallback with redacted from/to URLs', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
process.env.OPENAI_BASE_URL = 'http://user:supersecret@localhost:11434/v1'
process.env.OPENAI_API_KEY = 'supersecret'
globalThis.fetch = mock(async (input: string | Request) => {
const url = typeof input === 'string' ? input : input.url
if (url.includes('localhost')) {
throw Object.assign(new TypeError('fetch failed'), {
code: 'ENOTFOUND',
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'ok',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as typeof globalThis.fetch
const client = createOpenAIShimClient({}) as {
beta: {
messages: {
create: (params: Record<string, unknown>) => Promise<unknown>
}
}
}
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
const fallbackLog = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' &&
call[0].includes('self-heal retry reason=localhost_resolution_failed'),
)
expect(fallbackLog).toBeDefined()
const logLine = String(fallbackLog?.[0])
expect(logLine).toContain('from=http://redacted:redacted@localhost:11434/v1/chat/completions')
expect(logLine).toContain('to=http://redacted:redacted@127.0.0.1:11434/v1/chat/completions')
expect(logLine).not.toContain('supersecret')
})
test('logs self-heal toolless retry for local tool-call incompatibility', async () => {
const debugSpy = mock(() => {})
mock.module('../../utils/debug.js', () => ({
logForDebugging: debugSpy,
}))
const nonce = `${Date.now()}-${Math.random()}`
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
process.env.OPENAI_API_KEY = 'ollama'
let callCount = 0
globalThis.fetch = mock(async () => {
callCount += 1
if (callCount === 1) {
return new Response('tool_calls are not supported', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'ok',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 7,
completion_tokens: 3,
total_tokens: 10,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as typeof globalThis.fetch
const client = createOpenAIShimClient({}) as {
beta: {
messages: {
create: (params: Record<string, unknown>) => Promise<unknown>
}
}
}
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{
name: 'Read',
description: 'Read file',
input_schema: {
type: 'object',
properties: {
filePath: { type: 'string' },
},
required: ['filePath'],
},
},
],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
const fallbackLog = debugSpy.mock.calls.find(call =>
typeof call?.[0] === 'string' &&
call[0].includes('self-heal retry reason=tool_call_incompatible mode=toolless'),
)
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.[1]).toEqual({ level: 'warn' })
})

View File

@@ -2931,6 +2931,204 @@ test('classifies chat-completions endpoint 404 failures with endpoint_not_found
}),
).rejects.toThrow('openai_category=endpoint_not_found')
})
test('self-heals localhost resolution failures by retrying local loopback base URL', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
const requestUrls: string[] = []
globalThis.fetch = (async (input, _init) => {
const url = typeof input === 'string' ? input : input.url
requestUrls.push(url)
if (url.includes('localhost')) {
const error = Object.assign(new TypeError('fetch failed'), {
code: 'ENOTFOUND',
})
throw error
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'hello from loopback',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 4,
completion_tokens: 3,
total_tokens: 7,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
expect(requestUrls[0]).toBe('http://localhost:11434/v1/chat/completions')
expect(requestUrls).toContain('http://127.0.0.1:11434/v1/chat/completions')
})
test('self-heals local endpoint_not_found by retrying with /v1 base URL', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434'
const requestUrls: string[] = []
globalThis.fetch = (async (input, _init) => {
const url = typeof input === 'string' ? input : input.url
requestUrls.push(url)
if (url === 'http://localhost:11434/chat/completions') {
return new Response('Not Found', {
status: 404,
headers: {
'Content-Type': 'text/plain',
},
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'hello from /v1',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
expect(requestUrls).toEqual([
'http://localhost:11434/chat/completions',
'http://localhost:11434/v1/chat/completions',
])
})
test('self-heals tool-call incompatibility by retrying local Ollama requests without tools', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
const requestBodies: Array<Record<string, unknown>> = []
globalThis.fetch = (async (_input, init) => {
const requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>
requestBodies.push(requestBody)
if (requestBodies.length === 1) {
return new Response('tool_calls are not supported', {
status: 400,
headers: {
'Content-Type': 'text/plain',
},
})
}
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'qwen2.5-coder:7b',
choices: [
{
message: {
role: 'assistant',
content: 'fallback without tools',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 8,
completion_tokens: 4,
total_tokens: 12,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await expect(
client.beta.messages.create({
model: 'qwen2.5-coder:7b',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{
name: 'Read',
description: 'Read a file',
input_schema: {
type: 'object',
properties: {
filePath: { type: 'string' },
},
required: ['filePath'],
},
},
],
max_tokens: 64,
stream: false,
}),
).resolves.toBeDefined()
expect(requestBodies).toHaveLength(2)
expect(Array.isArray(requestBodies[0]?.tools)).toBe(true)
expect(requestBodies[0]?.tool_choice).toBeUndefined()
expect(
requestBodies[1]?.tools === undefined ||
(Array.isArray(requestBodies[1]?.tools) && requestBodies[1]?.tools.length === 0),
).toBe(true)
expect(requestBodies[1]?.tool_choice).toBeUndefined()
})
test('preserves valid tool_result and drops orphan tool_result', async () => {
let requestBody: Record<string, unknown> | undefined
@@ -2999,7 +3197,7 @@ test('preserves valid tool_result and drops orphan tool_result', async () => {
{
role: 'user',
content: 'What happened?',
}
},
],
},
],
@@ -3008,14 +3206,14 @@ test('preserves valid tool_result and drops orphan tool_result', async () => {
})
const messages = requestBody?.messages as Array<Record<string, unknown>>
// Should have: system, user, assistant (tool_use), tool (valid_call_1), user
// Should NOT have: tool (orphan_call_2)
const toolMessages = messages.filter(m => m.role === 'tool')
expect(toolMessages.length).toBe(1)
expect(toolMessages[0].tool_call_id).toBe('valid_call_1')
const orphanMessage = toolMessages.find(m => m.tool_call_id === 'orphan_call_2')
expect(orphanMessage).toBeUndefined()
})

View File

@@ -48,10 +48,12 @@ import {
} from './codexShim.js'
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
import {
getLocalProviderRetryBaseUrls,
getGithubEndpointType,
isLocalProviderUrl,
resolveRuntimeCodexCredentials,
resolveProviderRequest,
getGithubEndpointType,
shouldAttemptLocalToollessRetry,
} from './providerConfig.js'
import {
buildOpenAICompatibilityErrorMessage,
@@ -1427,48 +1429,95 @@ class OpenAIShimMessages {
headers['X-GitHub-Api-Version'] = '2022-11-28'
}
// Build the chat completions URL
// Azure Cognitive Services / Azure OpenAI require a deployment-specific path
// and an api-version query parameter.
// Standard format: {base}/openai/deployments/{model}/chat/completions?api-version={version}
// Non-Azure: {base}/chat/completions
let chatCompletionsUrl: string
if (isAzure) {
const apiVersion = process.env.AZURE_OPENAI_API_VERSION ?? '2024-12-01-preview'
const deployment = request.resolvedModel ?? process.env.OPENAI_MODEL ?? 'gpt-4o'
// If base URL already contains /deployments/, use it as-is with api-version
if (/\/deployments\//i.test(request.baseUrl)) {
const base = request.baseUrl.replace(/\/+$/, '')
chatCompletionsUrl = `${base}/chat/completions?api-version=${apiVersion}`
} else {
// Strip trailing /v1 or /openai/v1 if present, then build Azure path
const base = request.baseUrl.replace(/\/(openai\/)?v1\/?$/, '').replace(/\/+$/, '')
chatCompletionsUrl = `${base}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`
const buildChatCompletionsUrl = (baseUrl: string): string => {
// Azure Cognitive Services / Azure OpenAI require a deployment-specific
// path and an api-version query parameter.
if (isAzure) {
const apiVersion = process.env.AZURE_OPENAI_API_VERSION ?? '2024-12-01-preview'
const deployment = request.resolvedModel ?? process.env.OPENAI_MODEL ?? 'gpt-4o'
// If base URL already contains /deployments/, use it as-is with api-version.
if (/\/deployments\//i.test(baseUrl)) {
const normalizedBase = baseUrl.replace(/\/+$/, '')
return `${normalizedBase}/chat/completions?api-version=${apiVersion}`
}
// Strip trailing /v1 or /openai/v1 if present, then build Azure path.
const normalizedBase = baseUrl
.replace(/\/(openai\/)?v1\/?$/, '')
.replace(/\/+$/, '')
return `${normalizedBase}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`
}
} else {
chatCompletionsUrl = `${request.baseUrl}/chat/completions`
return `${baseUrl}/chat/completions`
}
const fetchInit = {
const localRetryBaseUrls = isLocal
? getLocalProviderRetryBaseUrls(request.baseUrl)
: []
let activeBaseUrl = request.baseUrl
let chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
const attemptedLocalBaseUrls = new Set<string>([activeBaseUrl])
let didRetryWithoutTools = false
const promoteNextLocalBaseUrl = (
reason: 'endpoint_not_found' | 'localhost_resolution_failed',
): boolean => {
for (const candidateBaseUrl of localRetryBaseUrls) {
if (attemptedLocalBaseUrls.has(candidateBaseUrl)) {
continue
}
const previousUrl = chatCompletionsUrl
attemptedLocalBaseUrls.add(candidateBaseUrl)
activeBaseUrl = candidateBaseUrl
chatCompletionsUrl = buildChatCompletionsUrl(activeBaseUrl)
logForDebugging(
`[OpenAIShim] self-heal retry reason=${reason} method=POST from=${redactUrlForDiagnostics(previousUrl)} to=${redactUrlForDiagnostics(chatCompletionsUrl)} model=${request.resolvedModel}`,
{ level: 'warn' },
)
return true
}
return false
}
let serializedBody = JSON.stringify(body)
const refreshSerializedBody = (): void => {
serializedBody = JSON.stringify(body)
}
const buildFetchInit = () => ({
method: 'POST' as const,
headers,
body: JSON.stringify(body),
body: serializedBody,
signal: options?.signal,
}
})
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
const maxSelfHealAttempts = isLocal
? localRetryBaseUrls.length + 1
: 0
const maxAttempts = (isGithub ? GITHUB_429_MAX_RETRIES : 1) + maxSelfHealAttempts
const throwClassifiedTransportError = (
error: unknown,
requestUrl: string,
preclassifiedFailure?: ReturnType<typeof classifyOpenAINetworkFailure>,
): never => {
if (options?.signal?.aborted) {
throw error
}
const failure = classifyOpenAINetworkFailure(error, {
url: requestUrl,
})
const failure =
preclassifiedFailure ??
classifyOpenAINetworkFailure(error, {
url: requestUrl,
})
const redactedUrl = redactUrlForDiagnostics(requestUrl)
const safeMessage =
redactSecretValueForDisplay(
@@ -1499,11 +1548,14 @@ class OpenAIShimMessages {
responseHeaders: Headers,
requestUrl: string,
rateHint = '',
preclassifiedFailure?: ReturnType<typeof classifyOpenAIHttpFailure>,
): never => {
const failure = classifyOpenAIHttpFailure({
status,
body: errorBody,
})
const failure =
preclassifiedFailure ??
classifyOpenAIHttpFailure({
status,
body: errorBody,
})
const redactedUrl = redactUrlForDiagnostics(requestUrl)
logForDebugging(
@@ -1525,10 +1577,13 @@ class OpenAIShimMessages {
let response: Response | undefined
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
response = await fetchWithProxyRetry(chatCompletionsUrl, fetchInit)
response = await fetchWithProxyRetry(
chatCompletionsUrl,
buildFetchInit(),
)
} catch (error) {
const isAbortError =
fetchInit.signal?.aborted === true ||
options?.signal?.aborted === true ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError') ||
@@ -1541,7 +1596,19 @@ class OpenAIShimMessages {
throw error
}
throwClassifiedTransportError(error, chatCompletionsUrl)
const failure = classifyOpenAINetworkFailure(error, {
url: chatCompletionsUrl,
})
if (
isLocal &&
failure.category === 'localhost_resolution_failed' &&
promoteNextLocalBaseUrl('localhost_resolution_failed')
) {
continue
}
throwClassifiedTransportError(error, chatCompletionsUrl, failure)
}
if (response.ok) {
@@ -1633,6 +1700,10 @@ class OpenAIShimMessages {
return responsesResponse
}
const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error')
const responsesFailure = classifyOpenAIHttpFailure({
status: responsesResponse.status,
body: responsesErrorBody,
})
let responsesErrorResponse: object | undefined
try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ }
throwClassifiedHttpError(
@@ -1641,10 +1712,49 @@ class OpenAIShimMessages {
responsesErrorResponse,
responsesResponse.headers,
responsesUrl,
'',
responsesFailure,
)
}
}
const failure = classifyOpenAIHttpFailure({
status: response.status,
body: errorBody,
})
if (
isLocal &&
failure.category === 'endpoint_not_found' &&
promoteNextLocalBaseUrl('endpoint_not_found')
) {
continue
}
const hasToolsPayload =
Array.isArray(body.tools) &&
body.tools.length > 0
if (
!didRetryWithoutTools &&
failure.category === 'tool_call_incompatible' &&
shouldAttemptLocalToollessRetry({
baseUrl: activeBaseUrl,
hasTools: hasToolsPayload,
})
) {
didRetryWithoutTools = true
delete body.tools
delete body.tool_choice
refreshSerializedBody()
logForDebugging(
`[OpenAIShim] self-heal retry reason=tool_call_incompatible mode=toolless method=POST url=${redactUrlForDiagnostics(chatCompletionsUrl)} model=${request.resolvedModel}`,
{ level: 'warn' },
)
continue
}
let errorResponse: object | undefined
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
throwClassifiedHttpError(
@@ -1654,6 +1764,7 @@ class OpenAIShimMessages {
response.headers as unknown as Headers,
chatCompletionsUrl,
rateHint,
failure,
)
}

View File

@@ -2,8 +2,10 @@ import { afterEach, expect, test } from 'bun:test'
import {
getAdditionalModelOptionsCacheScope,
getLocalProviderRetryBaseUrls,
isLocalProviderUrl,
resolveProviderRequest,
shouldAttemptLocalToollessRetry,
} from './providerConfig.js'
const originalEnv = {
@@ -83,3 +85,42 @@ test('skips local model cache scope for remote openai-compatible providers', ()
expect(getAdditionalModelOptionsCacheScope()).toBeNull()
})
test('derives local retry base URLs with /v1 and loopback fallback candidates', () => {
expect(getLocalProviderRetryBaseUrls('http://localhost:11434')).toEqual([
'http://localhost:11434/v1',
'http://127.0.0.1:11434',
'http://127.0.0.1:11434/v1',
])
})
test('does not derive local retry base URLs for remote providers', () => {
expect(getLocalProviderRetryBaseUrls('https://api.openai.com/v1')).toEqual([])
})
test('enables local toolless retry for likely Ollama endpoints with tools', () => {
expect(
shouldAttemptLocalToollessRetry({
baseUrl: 'http://localhost:11434/v1',
hasTools: true,
}),
).toBe(true)
})
test('disables local toolless retry when no tools are present', () => {
expect(
shouldAttemptLocalToollessRetry({
baseUrl: 'http://localhost:11434/v1',
hasTools: false,
}),
).toBe(false)
})
test('disables local toolless retry for non-Ollama local endpoints', () => {
expect(
shouldAttemptLocalToollessRetry({
baseUrl: 'http://localhost:1234/v1',
hasTools: true,
}),
).toBe(false)
})

View File

@@ -305,6 +305,101 @@ export function isLocalProviderUrl(baseUrl: string | undefined): boolean {
}
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '')
}
function normalizePathWithV1(pathname: string): string {
const trimmed = trimTrailingSlash(pathname)
if (!trimmed || trimmed === '/') {
return '/v1'
}
if (trimmed.toLowerCase().endsWith('/v1')) {
return trimmed
}
return `${trimmed}/v1`
}
function isLikelyOllamaEndpoint(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl)
const hostname = parsed.hostname.toLowerCase()
const pathname = parsed.pathname.toLowerCase()
if (parsed.port === '11434') {
return true
}
return (
hostname.includes('ollama') ||
pathname.includes('ollama')
)
} catch {
return false
}
}
export function getLocalProviderRetryBaseUrls(baseUrl: string): string[] {
if (!isLocalProviderUrl(baseUrl)) {
return []
}
try {
const parsed = new URL(baseUrl)
const original = trimTrailingSlash(parsed.toString())
const seen = new Set<string>([original])
const candidates: string[] = []
const addCandidate = (hostname: string, pathname: string): void => {
const next = new URL(parsed.toString())
next.hostname = hostname
next.pathname = pathname
next.search = ''
next.hash = ''
const normalized = trimTrailingSlash(next.toString())
if (seen.has(normalized)) {
return
}
seen.add(normalized)
candidates.push(normalized)
}
const v1Pathname = normalizePathWithV1(parsed.pathname)
if (v1Pathname !== trimTrailingSlash(parsed.pathname)) {
addCandidate(parsed.hostname, v1Pathname)
}
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (hostname === 'localhost' || hostname === '::1') {
addCandidate('127.0.0.1', parsed.pathname || '/')
addCandidate('127.0.0.1', v1Pathname)
}
return candidates
} catch {
return []
}
}
export function shouldAttemptLocalToollessRetry(options: {
baseUrl: string
hasTools: boolean
}): boolean {
if (!options.hasTools) {
return false
}
if (!isLocalProviderUrl(options.baseUrl)) {
return false
}
return isLikelyOllamaEndpoint(options.baseUrl)
}
export function isCodexBaseUrl(baseUrl: string | undefined): boolean {
if (!baseUrl) return false
try {
@@ -412,6 +507,9 @@ export function resolveProviderRequest(options?: {
? normalizedGeminiEnvBaseUrl
: asNamedEnvUrl(process.env.OPENAI_BASE_URL, 'OPENAI_BASE_URL')
// In Mistral mode, a literal "undefined" MISTRAL_BASE_URL is treated as
// misconfiguration and falls back to OPENAI_API_BASE, then
// DEFAULT_MISTRAL_BASE_URL for a safe default endpoint.
const fallbackEnvBaseUrl = isMistralMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_MISTRAL_BASE_URL

View File

@@ -70,7 +70,7 @@ describe('runAutoFixCheck', () => {
test('handles timeout gracefully', async () => {
const result = await runAutoFixCheck({
lint: 'sleep 10',
lint: 'node -e "setTimeout(() => {}, 10000)"',
timeout: 100,
cwd: '/tmp',

View File

@@ -46,14 +46,31 @@ async function runCommand(
const killTree = () => {
try {
if (!isWindows && proc.pid) {
if (isWindows && proc.pid) {
// shell=true on Windows can leave child commands running unless we
// terminate the full process tree.
const killer = spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
windowsHide: true,
stdio: 'ignore',
})
killer.unref()
return
}
if (proc.pid) {
// Kill the entire process group
process.kill(-proc.pid, 'SIGTERM')
} else {
proc.kill('SIGTERM')
return
}
proc.kill('SIGTERM')
} catch {
// Process may have already exited
// Process may have already exited; fallback to direct child kill.
try {
proc.kill('SIGTERM')
} catch {
// Ignore final fallback errors.
}
}
}

View File

@@ -26,10 +26,10 @@ test('initializeWiki creates the expected wiki scaffold', async () => {
expect(result.alreadyExisted).toBe(false)
expect(result.createdFiles).toEqual([
'.openclaude/wiki/schema.md',
'.openclaude/wiki/index.md',
'.openclaude/wiki/log.md',
'.openclaude/wiki/pages/architecture.md',
join('.openclaude', 'wiki', 'schema.md'),
join('.openclaude', 'wiki', 'index.md'),
join('.openclaude', 'wiki', 'log.md'),
join('.openclaude', 'wiki', 'pages', 'architecture.md'),
])
expect(await readFile(paths.schemaFile, 'utf8')).toContain(
'# OpenClaude Wiki Schema',