From af325b4a6201b9f3db4d300bd87452fa9189e773 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 4 Oct 2026 15:59:09 +0800 Subject: [PATCH] fix: ipapi.co stopped answering, so the lookup never resolved Turning IP Geolocation on changed nothing: every lookup failed, and the tool output still said "no Location is set in preferences", which is a different problem and sent the user looking in the wrong place. ipapi.co no longer serves an unauthenticated caller. Verified directly: no User-Agent -> HTTP 429 {"reason":"RateLimited", "message":"Please sign up for a paid plan"} browser UA -> HTTP 403, a Cloudflare "Just a moment..." challenge So the provider is now ipwho.is, which answers 200 without a key. It signals refusal with success: false on an HTTP 200, so the body is checked as well as the status, and it nests the timezone under an object, which the parser now accepts alongside a bare string. A named User-Agent is sent: node's default is bare, and "who is asking" is the one thing a geolocation provider has a fair interest in. The failure branch is also fixed. A failed lookup and an unset preference are different problems, and the wording no longer conflates them. The note tells the model to pass the failure on, because the model is the only channel that reaches the user and it had been paraphrasing the failure away. Accuracy is documented honestly rather than as "city-level". An IP locates the ISP's egress, not the person: measured from one machine, an IPv4 query resolved to Cebu City and an IPv6 query moments later to Talisay in Calabarzon, about 500km apart. That is now in the README, the preference description and the tool output, and it is the argument for setting Location instead. Verified live with the exact request and field access the extension uses: HTTP 200, "Talisay, Calabarzon (Region IV-A), Philippines", Asia/Manila. 344 tests, tsc, eslint, prettier, and vici build. --- README.md | 10 +++++++-- package.json | 2 +- src/__tests__/geo.test.ts | 37 +++++++++++++++++++++---------- src/__tests__/tools.test.ts | 32 ++++++++++++++++++++++----- src/lib/geo.ts | 44 +++++++++++++++++++++++++++---------- src/lib/tools.ts | 14 +++++++----- src/setup.tsx | 2 +- 7 files changed, 102 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index d79085b..4016947 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this' | **Model** | None | Required. Any model your key can reach. | | **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. | | **Location** | None | The place Lam should treat as yours, for example `Cebu City, Philippines`. Optional. The only exact location available, and the only one that stops anything being inferred. | -| **IP Geolocation** | off | Off by default. When on, and only when **Location** is empty, your IP is sent to ipapi.co to resolve a rough city. | +| **IP Geolocation** | off | Off by default. When on, and only when **Location** is empty, your IP is sent to ipwho.is to resolve a rough area. | | **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. | | **Web Tools** | on | Enables `web_search` and `web_fetch`. | | **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. | @@ -148,10 +148,16 @@ Lam calls these on its own when they would improve an answer. All four are read- The **region** comes from your locale, which is a declaration rather than an inference: a locale of `en-PH` says the Philippines because that is what you set. It costs nothing and it cannot be wrong the way a zone name can. -If you turn on **IP Geolocation**, and only then, `get_location` resolves a rough city from your IP address via [ipapi.co](https://ipapi.co). That is a third party learning approximately where you are, so it is off by default, it is skipped entirely whenever you have set a Location (the better answer is already there, and there is no reason to spend your privacy on a worse one), and the tool output names the provider and says the result is city-level rather than exact. The answer is cached for 6 hours rather than looked up per question, because a city does not change between questions and every lookup hands over another copy of your address. `Set Up Lam Agent` clears both that cache and the capability cache when you re-test. +If you turn on **IP Geolocation**, and only then, `get_location` resolves a rough area from your IP address via [ipwho.is](https://ipwho.is). That is a third party learning approximately where you are, so it is off by default, it is skipped entirely whenever you have set a Location (the better answer is already there, and there is no reason to spend your privacy on a worse one), and the tool output names the provider and calls the result a rough guess rather than a city. + +**It is coarser than "city-level" suggests, and that is worth knowing before trusting it.** An IP address identifies the point where the traffic left the ISP, not the person. Measured from one machine on Globe Telecom, an IPv4 lookup resolved to Cebu City and an IPv6 lookup moments later resolved to Talisay in Calabarzon, roughly 500km apart. If you want a place you can rely on, set **Location** and turn this off. + +The answer is cached for 6 hours rather than looked up per question, because an area does not change between questions and every lookup hands over another copy of your address. `Set Up Lam Agent` clears that cache, and the capability cache, when you re-test. This extension does not use GPS. That would mean CoreLocation on macOS, which has no command-line interface, or GeoClue on Linux, whose usual provider resolves nearby Wi-Fi networks through a Mozilla service anyway. Neither is worth it for "is it late where you are". +**The provider has to be one that answers an unauthenticated caller.** The first choice was ipapi.co, which no longer does: a request with no User-Agent now returns `HTTP 429` with `"Please sign up for a paid plan"`, and adding a browser User-Agent gets an `HTTP 403` Cloudflare bot challenge instead. ipwho.is answers without a key and signals failures with `success: false` on an `HTTP 200`, so the body is checked as well as the status. `GEO_PROVIDER` is a single constant, and swapping it means changing the parser beside it. + Web search results and fetched pages can be large. Lam truncates them before they reach the model, and gives up after 5 rounds of tool calls rather than looping. ## Architecture diff --git a/package.json b/package.json index 4bc40cc..d0c56ef 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "name": "ipGeolocation", "title": "IP Geolocation", "label": "Look up my city from my IP address", - "description": "Off by default. When this is on, and the Location preference above is empty, Lam sends your IP address to ipapi.co to work out your city. That is a third party learning roughly where you are, which is why it is opt-in, and why setting Location makes it unnecessary.", + "description": "Off by default. When this is on, and the Location preference above is empty, Lam sends your IP address to ipwho.is to work out roughly where you are. That is a third party learning approximately where you are, which is why it is opt-in, and why setting Location makes it unnecessary. It is coarser than a city: an IP address locates the point where traffic left your ISP, which can be hundreds of kilometres away.", "type": "checkbox", "required": false, "default": false diff --git a/src/__tests__/geo.test.ts b/src/__tests__/geo.test.ts index f9b1231..abc86ab 100644 --- a/src/__tests__/geo.test.ts +++ b/src/__tests__/geo.test.ts @@ -18,13 +18,15 @@ function answer(payload: unknown, status = 200): Response { }); } +/** A real ipwho.is payload, trimmed. Note the nested timezone and `country`. */ const CEBU = { + success: true, city: "Cebu City", region: "Central Visayas", - country_name: "Philippines", + country: "Philippines", latitude: 10.3167, longitude: 123.8907, - timezone: "Asia/Manila", + timezone: { id: "Asia/Manila", utc: "+08:00" }, }; beforeEach(() => { @@ -74,16 +76,26 @@ describe("ipPlace", () => { expect(result.place.place).toBe("Cebu City, Central Visayas, Philippines"); expect(result.place.latitude).toBe(10.3167); expect(result.place.timezone).toBe("Asia/Manila"); - expect(result.place.source).toBe("ipapi.co"); + expect(result.place.source).toBe("ipwho.is"); }); - it("asks for JSON with no cache-busting that a cache would defeat", async () => { + it("names itself to the provider rather than arriving anonymously", async () => { fetchMock.mockResolvedValueOnce(answer(CEBU)); await ipPlace({ now: 1_000 }); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("https://ipapi.co/json/"); + expect(url).toBe("https://ipwho.is/"); expect(init.headers.Accept).toBe("application/json"); + expect(init.headers["User-Agent"]).toContain("lam-agent"); + }); + + it("reads a timezone handed back as a plain string too", async () => { + // Providers differ here: ipwho.is nests it, others return a bare zone name. + fetchMock.mockResolvedValueOnce(answer({ ...CEBU, timezone: "Asia/Manila" })); + + const result = await ipPlace({ now: 1_000 }); + + expect(result.status === "ok" && result.place.timezone).toBe("Asia/Manila"); }); it("serves a repeat from the cache instead of re-sending the address", async () => { @@ -132,16 +144,17 @@ describe("ipPlace", () => { const result = await ipPlace({ now: 1_000 }); - expect(result).toEqual({ status: "failed", message: "ipapi.co answered HTTP 429" }); + expect(result).toEqual({ status: "failed", message: "ipwho.is answered HTTP 429" }); }); - it("reports a provider that answers with error: true", async () => { - // ipapi.co signals failure in the body while still answering 200. - fetchMock.mockResolvedValueOnce(answer({ error: true, reason: "RateLimited", message: "Too many" })); + it("reports a provider that refuses in the body while answering 200", async () => { + // ipwho.is signals failure with success: false rather than a status code, + // so checking the status alone would read a refusal as a success. + fetchMock.mockResolvedValueOnce(answer({ success: false, message: "Reserved range" })); const result = await ipPlace({ now: 1_000 }); - expect(result).toEqual({ status: "failed", message: "Too many" }); + expect(result).toEqual({ status: "failed", message: "Reserved range" }); }); it("reports a body that is not JSON", async () => { @@ -153,11 +166,11 @@ describe("ipPlace", () => { }); it("reports an answer with no usable place", async () => { - fetchMock.mockResolvedValueOnce(answer({ city: null, region: null, country_name: null })); + fetchMock.mockResolvedValueOnce(answer({ success: true, city: null, region: null, country: null })); const result = await ipPlace({ now: 1_000 }); - expect(result).toEqual({ status: "failed", message: "ipapi.co returned no usable place" }); + expect(result).toEqual({ status: "failed", message: "ipwho.is returned no usable place" }); }); it("survives the network being down without throwing", async () => { diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 4aeb59b..2451d81 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -273,7 +273,7 @@ describe("get_location with IP geolocation", () => { region: "Central Visayas", country: "Philippines", timezone: "Asia/Manila", - source: "ipapi.co", + source: "ipwho.is", fetchedAt: 1_000, }, }; @@ -285,8 +285,8 @@ describe("get_location with IP geolocation", () => { expect(result.isError).toBe(false); expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines"); - expect(result.content).toContain("city-level rather than exact"); - expect(result.content).toContain("ipapi.co"); + expect(result.content).toContain("rough guess"); + expect(result.content).toContain("ipwho.is"); }); it("does not look the address up at all when the user has said where they are", async () => { @@ -302,20 +302,40 @@ describe("get_location with IP geolocation", () => { expect(ipPlace).not.toHaveBeenCalled(); expect(result.content).toContain("Place: Cebu City, Philippines"); - expect(result.content).not.toContain("ipapi.co"); + expect(result.content).not.toContain("ipwho.is"); }); it("surfaces a failed lookup instead of quietly serving the region answer", async () => { - const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipapi.co answered HTTP 429" })); + const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" })); const result = await executeTool("get_location", {}, { ...context, ipPlace }); - expect(result.content).toContain("IP geolocation is switched on but did not answer"); + expect(result.content).toContain("IP geolocation is switched on but the lookup failed"); expect(result.content).toContain("HTTP 429"); // The region answer is still there, so the tool stays useful. expect(result.content).toContain("Timezone:"); }); + it("does not blame an unset preference when the lookup itself failed", async () => { + // These are different problems and send the user to different places. The + // original wording said "no Location is set in preferences" for both, which + // is exactly what made a broken lookup look like a missing setting. + const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" })); + + const result = await executeTool("get_location", {}, { ...context, ipPlace }); + + expect(result.content).not.toContain("no Location is set in preferences"); + }); + + it("tells the model to pass a failed lookup on, since it is the only channel to the user", async () => { + const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "offline" })); + + const result = await executeTool("get_location", {}, { ...context, ipPlace }); + + expect(result.content).toContain("Tell the user"); + expect(result.content).toContain("Setting Location in preferences avoids the lookup"); + }); + it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => { const ipPlace = vi.fn(async () => ({ status: "ok" as const, diff --git a/src/lib/geo.ts b/src/lib/geo.ts index b8df71d..ca7cb47 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -11,6 +11,13 @@ import { errorMessage } from "./utils"; * skipped entirely when the user has already said where they are, and the tool * output names the provider so the model can qualify what it repeats. * + * How accurate is it? Less than the name suggests, and the honest answer is that + * an IP address locates the point where the traffic left the ISP, not the person. + * Measured from one machine on Globe Telecom, an IPv4 query resolved to Cebu City + * and an IPv6 query, moments later, to Talisay in Calabarzon: about 500km apart. + * The cache is therefore not just a privacy measure, and the tool output calls + * the result a rough guess rather than a city. + * * The answer is cached for hours rather than looked up per call. A city does not * change between questions, and every lookup hands the provider another copy of * the user's IP address. @@ -21,8 +28,8 @@ import { errorMessage } from "./utils"; */ /** Where the lookup goes. Swapping providers means changing this and the parser below. */ -export const GEO_PROVIDER = "https://ipapi.co/json/"; -export const GEO_PROVIDER_NAME = "ipapi.co"; +export const GEO_PROVIDER = "https://ipwho.is/"; +export const GEO_PROVIDER_NAME = "ipwho.is"; /** How long a resolved place is trusted. Long enough to not re-leak the IP on every question. */ export const GEO_TTL_MS = 6 * 60 * 60 * 1000; @@ -68,17 +75,20 @@ const CachedSchema = z.object({ fetchedAt: z.number(), }); -/** The subset of ipapi.co's response this needs, with every field optional so a shape change degrades instead of throwing. */ +/** The subset of the provider's response this needs, with every field optional so a shape change degrades instead of throwing. */ const ResponseSchema = z.object({ - error: z.unknown().optional(), - reason: z.string().optional(), + success: z.boolean().optional(), message: z.string().optional(), city: z.string().nullable().optional(), region: z.string().nullable().optional(), - country_name: z.string().nullable().optional(), + country: z.string().nullable().optional(), latitude: z.number().nullable().optional(), longitude: z.number().nullable().optional(), - timezone: z.string().nullable().optional(), + // ipwho.is nests the timezone; some providers hand back a plain string. + timezone: z + .union([z.string(), z.object({ id: z.string().optional() })]) + .nullable() + .optional(), }); /** @@ -117,7 +127,15 @@ async function fetchWithTimeout( signal?.addEventListener("abort", forward, { once: true }); try { - return await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" } }); + return await fetch(url, { + signal: controller.signal, + headers: { + Accept: "application/json", + // Named rather than anonymous. Node's default UA is bare, and "who is + // asking" is the one thing a geolocation provider has a fair interest in. + "User-Agent": "lam-agent-vicinae", + }, + }); } finally { clearTimeout(timer); signal?.removeEventListener("abort", forward); @@ -182,22 +200,24 @@ export async function ipPlace(options: GeoOptions = {}): Promise { } const data = parsed.data; - if (data.error) { + if (data.success === false) { return { status: "failed", - message: data.message ?? data.reason ?? `${GEO_PROVIDER_NAME} refused the lookup`, + message: data.message ?? `${GEO_PROVIDER_NAME} refused the lookup`, }; } const city = data.city ?? undefined; const region = data.region ?? undefined; - const country = data.country_name ?? undefined; + const country = data.country ?? undefined; const place = buildPlace({ city, region, country }); if (!place) { return { status: "failed", message: `${GEO_PROVIDER_NAME} returned no usable place` }; } + const timezone = typeof data.timezone === "string" ? data.timezone : data.timezone?.id; + const resolved: GeoPlace = { place, city, @@ -205,7 +225,7 @@ export async function ipPlace(options: GeoOptions = {}): Promise { country, latitude: data.latitude ?? undefined, longitude: data.longitude ?? undefined, - timezone: data.timezone ?? undefined, + timezone: timezone ?? undefined, source: GEO_PROVIDER_NAME, fetchedAt: now, }; diff --git a/src/lib/tools.ts b/src/lib/tools.ts index f76f626..40836df 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -231,7 +231,15 @@ async function describeLocation(context: ToolContext): Promise { ); } else if (ip) { lines.push( - `Note: Place came from an IP address lookup via ${ip.source}, so it is city-level rather than exact, and it can be out of date if the user has moved since.`, + `Note: Place came from an IP address lookup via ${ip.source}, so treat it as a rough guess rather than the user's city. An IP address locates where the request left the ISP's network, which can be hundreds of kilometres away, and it can be out of date if they have moved since.`, + ); + } else if (lookup?.status === "failed") { + // Deliberately not the "no Location is set" wording below. A failed lookup + // and an unset preference are different problems, and reporting the wrong + // one sends the user looking in the wrong place. This is also the only + // channel that reaches them: the model paraphrases, so it is told to say so. + lines.push( + `Note: IP geolocation is switched on but the lookup failed (${lookup.message}), so no place could be resolved. Tell the user this plainly. Setting Location in preferences avoids the lookup and the network entirely. Everything below is derived from the device timezone and locale, so it is region-level.`, ); } else { lines.push( @@ -239,10 +247,6 @@ async function describeLocation(context: ToolContext): Promise { ); } - if (lookup?.status === "failed") { - lines.push(`IP geolocation is switched on but did not answer: ${lookup.message}.`); - } - if (location.timezoneArea) { lines.push("The timezone area is the timezone's own name, not the user's city."); } diff --git a/src/setup.tsx b/src/setup.tsx index aae6273..5443ca2 100644 --- a/src/setup.tsx +++ b/src/setup.tsx @@ -188,7 +188,7 @@ export default function SetupCommand() { preferences.place ? `${preferences.place} (from preferences)` : preferences.ipGeolocation - ? "Rough city, resolved from the IP address via ipapi.co" + ? "Rough area, resolved from the IP address via ipwho.is" : "Region only, from the timezone and locale" } actions={settingsActions}