Merge pull request #73 from Pavelevich/codex/fix-ollama-ipv6-fallback

fix: add Ollama IPv6 loopback fallback
This commit is contained in:
signerless 2026-03-26 14:13:00 +01:00 • committed by GitHub
commit 59eb705ead
7 changed files with 261 additions and 3 deletions

View file

@ -187,7 +187,7 @@ const ALLOWED_CLI_COMMANDS = new Set([
const server = new McpServer({
name: "llm-checker",
version: "3.5.9",
version: "3.5.10",
});
// ============================================================================

View file

@ -1,6 +1,18 @@
Changelog
=========
3.5.10 — Ollama IPv6 Loopback Fallback (2026-03-26)
---------------------------------------------------
- Fixed a remaining Ollama loopback fallback bug on systems where:
- `localhost` does not resolve correctly for Node fetch calls.
- `127.0.0.1` also fails.
- Ollama is reachable on IPv6 loopback (`::1`).
- Corrected the shared Ollama client to construct a real bracketed IPv6 fallback URL (`http://[::1]:11434`) instead of accidentally retrying `localhost` again.
- Added regression coverage for:
- shared client availability fallback from `localhost` to IPv4 to IPv6.
- selector probe and evaluator requests continuing to use the resolved IPv6 base URL after fallback succeeds.
3.5.9 — Selector Loopback Fallback + Windows Backend Follow-up (2026-03-26)
----------------------------------------------------------------------------

View file

@ -1,6 +1,6 @@
{
"name": "llm-checker",
"version": "3.5.9",
"version": "3.5.10",
"description": "Intelligent CLI tool with AI-powered model selection that analyzes your hardware and recommends optimal LLM models for your system",
"bin": {
"llm-checker": "bin/cli.js",

View file

@ -35,7 +35,8 @@ class OllamaClient {
candidates.push(ipv4.toString().replace(/\/$/, ''));
const ipv6 = new URL(parsed.toString());
ipv6.hostname = '::1';
// URL.hostname expects bracketed IPv6 literals when mutating an existing URL.
ipv6.hostname = '[::1]';
candidates.push(ipv6.toString().replace(/\/$/, ''));
}
} catch (error) {

View file

@ -0,0 +1,105 @@
const assert = require('assert');
const OllamaClient = require('../src/ollama/client');
function createJsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'ERROR',
async json() {
return payload;
},
async text() {
return JSON.stringify(payload);
}
};
}
async function testLocalhostFallsBackToIpv6WhenIpv4Fails() {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(url);
if (url === 'http://localhost:11434/api/version') {
const error = new Error('request to http://localhost:11434/api/version failed, reason: connect ECONNREFUSED 127.0.0.1:11434');
error.code = 'ECONNREFUSED';
throw error;
}
if (url === 'http://127.0.0.1:11434/api/version') {
const error = new Error('request to http://127.0.0.1:11434/api/version failed, reason: connect ECONNREFUSED 127.0.0.1:11434');
error.code = 'ECONNREFUSED';
throw error;
}
if (url === 'http://[::1]:11434/api/version') {
return createJsonResponse({ version: '0.18.2' });
}
if (url === 'http://[::1]:11434/api/tags') {
return createJsonResponse({
models: [
{
name: 'llama3.2:3b',
size: 2147483648,
digest: 'abc123',
modified_at: '2026-03-25T00:00:00Z',
details: {
family: 'llama',
parameter_size: '3B',
quantization_level: 'Q4_K_M',
format: 'gguf'
}
}
]
});
}
throw new Error(`Unexpected URL in IPv6 fallback test: ${url}`);
};
try {
const client = new OllamaClient('http://localhost:11434');
const availability = await client.checkOllamaAvailability();
assert.strictEqual(availability.available, true, 'availability should succeed via IPv6 fallback URL');
assert.strictEqual(client.baseURL, 'http://[::1]:11434', 'client should persist working IPv6 fallback base URL');
assert.deepStrictEqual(
calls.slice(0, 3),
[
'http://localhost:11434/api/version',
'http://127.0.0.1:11434/api/version',
'http://[::1]:11434/api/version'
],
'availability check should retry localhost with IPv4 and IPv6 fallback URLs'
);
const models = await client.getLocalModels();
assert.strictEqual(models.length, 1, 'model listing should use the resolved IPv6 fallback URL');
assert.strictEqual(models[0].name, 'llama3.2:3b');
assert.strictEqual(
calls[calls.length - 1],
'http://[::1]:11434/api/tags',
'subsequent requests should use the resolved IPv6 fallback URL'
);
} finally {
global.fetch = originalFetch;
}
}
async function run() {
await testLocalhostFallsBackToIpv6WhenIpv4Fails();
console.log('ollama-client-ipv6-fallback.test.js: OK');
}
if (require.main === module) {
run().catch((error) => {
console.error('ollama-client-ipv6-fallback.test.js: FAILED');
console.error(error);
process.exit(1);
});
}
module.exports = { run };

View file

@ -0,0 +1,138 @@
const assert = require('assert');
const DeterministicModelSelector = require('../src/models/deterministic-selector');
const AICheckSelector = require('../src/models/ai-check-selector');
function createJsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'ERROR',
async json() {
return payload;
},
async text() {
return JSON.stringify(payload);
}
};
}
async function testProbeAndEvaluatorUseResolvedIpv6FallbackURL() {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url, options });
if (url === 'http://localhost:11434/api/version') {
const error = new Error('request to http://localhost:11434/api/version failed, reason: connect ECONNREFUSED 127.0.0.1:11434');
error.code = 'ECONNREFUSED';
throw error;
}
if (url === 'http://127.0.0.1:11434/api/version') {
const error = new Error('request to http://127.0.0.1:11434/api/version failed, reason: connect ECONNREFUSED 127.0.0.1:11434');
error.code = 'ECONNREFUSED';
throw error;
}
if (url === 'http://[::1]:11434/api/version') {
return createJsonResponse({ version: '0.18.2' });
}
if (url === 'http://[::1]:11434/api/generate') {
const body = JSON.parse(options.body);
assert.strictEqual(body.model, 'llama3.2:3b');
assert.strictEqual(body.options.num_predict, 128);
return createJsonResponse({
response: 'unit tests keep regressions contained',
eval_count: 96,
eval_duration: 2_000_000_000
});
}
if (url === 'http://[::1]:11434/api/chat') {
const body = JSON.parse(options.body);
assert.strictEqual(body.model, 'llama3.2:3b');
assert.strictEqual(body.options.temperature, 0.1);
assert.strictEqual(body.options.num_ctx, 4096);
return createJsonResponse({
message: {
content: JSON.stringify({
winner: 'llama3.2:3b',
ranking: [
{
name: 'llama3.2:3b',
aiScore: 88,
shortWhy: 'best local fit'
}
]
})
}
});
}
throw new Error(`Unexpected URL in selector IPv6 fallback test: ${url}`);
};
try {
const deterministicSelector = new DeterministicModelSelector();
const probeTPS = await deterministicSelector.runSingleProbe('llama3.2:3b', 'general');
assert(Number.isFinite(probeTPS) && probeTPS > 0, `Expected positive probe TPS, got ${probeTPS}`);
assert.strictEqual(
deterministicSelector.ollamaClient.baseURL,
'http://[::1]:11434',
'probe path should persist the resolved IPv6 fallback base URL'
);
const aiCheckSelector = new AICheckSelector();
const aiResult = await aiCheckSelector.callOllamaEvaluator('llama3.2:3b', {
hardware: { category: 'general' },
candidates: [
{
name: 'llama3.2:3b',
paramsB: 3,
quant: 'Q4_K_M',
requiredGB: 2,
installed: true
}
]
});
assert.strictEqual(aiResult.winner, 'llama3.2:3b');
assert.strictEqual(aiResult.ranking.length, 1);
assert.strictEqual(aiResult.ranking[0].name, 'llama3.2:3b');
assert.strictEqual(
aiCheckSelector.ollamaClient.baseURL,
'http://[::1]:11434',
'evaluator path should persist the resolved IPv6 fallback base URL'
);
const urls = calls.map((entry) => entry.url);
assert.ok(urls.includes('http://[::1]:11434/api/generate'), 'probe path should use IPv6 fallback URL');
assert.ok(urls.includes('http://[::1]:11434/api/chat'), 'evaluator path should use IPv6 fallback URL');
assert.ok(!urls.includes('http://localhost:11434/api/generate'), 'probe path should not keep using localhost after fallback');
assert.ok(!urls.includes('http://127.0.0.1:11434/api/generate'), 'probe path should not keep using IPv4 after IPv6 fallback succeeds');
assert.ok(!urls.includes('http://localhost:11434/api/chat'), 'evaluator path should not keep using localhost after fallback');
assert.ok(!urls.includes('http://127.0.0.1:11434/api/chat'), 'evaluator path should not keep using IPv4 after IPv6 fallback succeeds');
} finally {
global.fetch = originalFetch;
}
}
async function run() {
await testProbeAndEvaluatorUseResolvedIpv6FallbackURL();
console.log('ollama-selector-ipv6-fallback.test.js: OK');
}
if (require.main === module) {
run().catch((error) => {
console.error('ollama-selector-ipv6-fallback.test.js: FAILED');
console.error(error);
process.exit(1);
});
}
module.exports = { run };

View file

@ -19,7 +19,9 @@ const TESTS = [
{ name: 'Fine-tuning support helper', file: 'fine-tuning-support.test.js', category: 'Recommendations' },
{ name: 'Ollama capacity planner', file: 'ollama-capacity-planner.test.js', category: 'Ollama' },
{ name: 'Ollama localhost fallback', file: 'ollama-client-localhost-fallback.test.js', category: 'Ollama' },
{ name: 'Ollama IPv6 fallback', file: 'ollama-client-ipv6-fallback.test.js', category: 'Ollama' },
{ name: 'Ollama selector localhost fallback', file: 'ollama-selector-localhost-fallback.test.js', category: 'Ollama' },
{ name: 'Ollama selector IPv6 fallback', file: 'ollama-selector-ipv6-fallback.test.js', category: 'Ollama' },
{ name: 'Ollama speed metrics', file: 'ollama-client-speed-metrics.test.js', category: 'Ollama' },
{ name: 'Runtime + speculative decoding', file: 'runtime-specdec-tests.js', category: 'Runtime' },
{ name: 'CLI interface smoke', file: 'ui-cli-smoke.test.js', category: 'UI' },