Skip to content
Merged
13 changes: 13 additions & 0 deletions changelogs/v2.5.19.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Fixes

- **Gateway `provider:` prefixes and word-suffixed model ids now resolve their real context window.** The models.dev context-window lookup normalizes proxy/gateway model ids before matching, but two id shapes fell through: a leading `provider:` prefix (e.g. `merge:deepseek-v4-flash` or `merge:gpt-5.6-luna`) was mistaken for the model name, and the trailing version-tag stripper (`-v\d+.*`) was greedy — it swallowed everything after `-vN`, collapsing `deepseek-v4-flash` to `deepseek`. Cairn now strips a leading `provider:` prefix first (whether or not the id also has a `/` path), and only strips a *pure* trailing version tag (`-v1`, `-v1:0`) so descriptive suffixes like `-flash` and `-luna` survive. Added unit tests for `normalizeId` covering these cases.

- **Community connector logos stay legible on both themes.** Browse Community now renders each entry's logo on a fixed light "chip" (like an app-store icon tile), so a dark monochrome brand mark — e.g. a charcoal provider logo — no longer disappears against the dark theme's near-black card. Connectors without a logo show a theme-safe generic glyph (a plug for services/providers, the MCP mark for MCP servers) on a subtle neutral tile, instead of being tinted with a `brandColor` that could vanish on one theme. Logos are also a bit larger and better aligned.

- **"Refresh" in Browse Community now forces a true re-download.** The registry Refresh sent a conditional request with the cached ETag, so a stale CDN edge could answer "304 Not Modified" and keep serving the old catalog even right after an update (e.g. a newly-added provider icon). An explicit Refresh now skips the ETag and cache-busts the request, so it always pulls the latest `manifest.json` / `providers.json`. Background revalidation still uses the efficient conditional request.

- **Model picker: tighter favourite rows.** Removed the empty check-mark spacer column that pushed model names far to the right of the favourite star; the active-model check now renders inline, so names sit right next to the star.

## Features

- **Browse and install community AI providers.** A new **Browse Community** button in both **Settings → AI & Chat** and **Settings → Coding Agent** (OpenAI-compatible provider) installs ready-made provider presets from the cairn-community catalog with one click — you just enter your API key, which is stored in your OS keychain (never in the database or synced). Each preset ships an endpoint base URL and a default model, so there's no more copy-pasting gateway URLs. Installed providers are added to your shared provider list (used by both AI Chat and the coding agent); pick one in the provider switcher to use it. Community providers live in a **separate `providers.json` manifest** from the tools/commands catalog, are fetched cache-first (instant/offline, background-revalidated), and re-installing a preset reuses the existing entry (matched by community id or name) instead of duplicating it. Each preset's endpoint base URL (and the optional "get a key" link) must be a valid HTTPS URL, and the default model is validated separately as a model identifier before install.
11 changes: 10 additions & 1 deletion electron/ipc/community-registry-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@

import { registerIpcHandle } from "./registry";
import { handle } from "./result-helpers";
import { fetchManifest, refreshManifest } from "../lib/community-registry";
import {
fetchManifest,
refreshManifest,
fetchProvidersManifest,
refreshProvidersManifest,
} from "../lib/community-registry";

export function registerCommunityRegistryHandlers(): void {
registerIpcHandle("registry:fetch", () => handle(() => fetchManifest()));
registerIpcHandle("registry:refresh", () => handle(() => refreshManifest()));
// Providers live in a SEPARATE manifest (providers.json) so the catalogs can
// evolve independently.
registerIpcHandle("registry:fetchProviders", () => handle(() => fetchProvidersManifest()));
registerIpcHandle("registry:refreshProviders", () => handle(() => refreshProvidersManifest()));
}
35 changes: 28 additions & 7 deletions electron/lib/community-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,22 +198,43 @@ describe("fetchManifest", () => {
expect(res.error).toBe("dns");
});

it("honours 304 Not Modified by serving the cache", async () => {
it("forced refresh is a hard refresh: no If-None-Match, cache-busted, always re-downloads", async () => {
vi.stubGlobal("fetch", vi.fn(async () => fetchResponse({ status: 200, json: VALID, etag: 'W/"v1"' })));
await fetchManifest({ force: true }); // seed cache + etag

// A second forced fetch must NOT send the conditional ETag header (so a
// stale CDN edge can't answer 304 with old content) and must cache-bust the
// URL, guaranteeing fresh content on an explicit Refresh.
const spy = vi.fn(async () => fetchResponse({ status: 200, json: VALID, etag: 'W/"v2"' }));
vi.stubGlobal("fetch", spy);
const res = await fetchManifest({ force: true });

expect(res.fromCache).toBe(false);
const [url, init] = spy.mock.calls[0] as unknown as [string, { headers: Record<string, string> }];
expect(init.headers["If-None-Match"]).toBeUndefined();
expect(url).toMatch(/[?&]_cb=\d+/);
});

it("honours 304 Not Modified on background revalidation (non-forced)", async () => {
// Seed a cache + etag via a forced fetch.
vi.stubGlobal("fetch", vi.fn(async () => fetchResponse({ status: 200, json: VALID, etag: 'W/"v1"' })));
await fetchManifest({ force: true });

// A NON-forced fetch serves the cache immediately and revalidates in the
// background with the conditional ETag; a 304 keeps the cached copy intact.
const spy = vi.fn(async () => fetchResponse({ status: 304 }));
vi.stubGlobal("fetch", spy);
const res = await fetchManifest({ force: true });
const res = await fetchManifest();
expect(res.fromCache).toBe(true);
// The cache is served intact — the previously cached VALID entries survive
// and no error is surfaced.
expect(res.error).toBeUndefined();
expect(res.manifest.mcpServers[0].definition.name).toBe("Jira");
expect(res.manifest.services[0].definition.name).toBe("Weather");
// Sent the conditional header.
const headers = (spy.mock.calls[0] as unknown as [string, { headers: Record<string, string> }])[1].headers;
expect(headers["If-None-Match"]).toBe('W/"v1"');
// Let the background revalidation run and assert it sent the conditional header.
await new Promise((r) => setTimeout(r, 0));
if (spy.mock.calls.length > 0) {
const init = (spy.mock.calls[0] as unknown as [string, { headers: Record<string, string> }])[1];
expect(init.headers["If-None-Match"]).toBe('W/"v1"');
}
});

it("cache-first (non-forced) returns the cache without awaiting the network", async () => {
Expand Down
156 changes: 117 additions & 39 deletions electron/lib/community-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,25 @@
import fs from "fs";
import path from "path";
import { findUserDataDir } from "../runtime/port-discovery";
import { parseManifest, type CommunityManifest } from "../../shared/chat/registry-schema";
import {
parseManifest,
parseProvidersManifest,
type CommunityManifest,
type ProvidersManifest,
} from "../../shared/chat/registry-schema";

// Manifest TYPES + Zod validation now live in shared/chat/registry-schema.ts so
// desktop and mobile validate the catalog identically. Re-export the types so
// existing electron importers keep resolving them from here.
export type {
CommunityManifest,
ProvidersManifest,
RegistryMcpEntry,
RegistryServiceEntry,
RegistryProviderEntry,
RegistryEntryMeta,
} from "../../shared/chat/registry-schema";
export { parseManifest } from "../../shared/chat/registry-schema";
export { parseManifest, parseProvidersManifest } from "../../shared/chat/registry-schema";

export interface RegistryFetchResult {
manifest: CommunityManifest;
Expand All @@ -38,40 +45,50 @@ export interface RegistryFetchResult {
error?: string;
}

export interface ProvidersFetchResult {
manifest: ProvidersManifest;
fromCache: boolean;
cachedAt?: string;
error?: string;
}

const MANIFEST_URL =
"https://raw.githubusercontent.com/ddutchie/cairn-community/main/manifest.json";
const PROVIDERS_URL =
"https://raw.githubusercontent.com/ddutchie/cairn-community/main/providers.json";
const CACHE_FILE = "community-registry.json";
const PROVIDERS_CACHE_FILE = "community-providers.json";
const FETCH_TIMEOUT_MS = 10_000;

// ── cache ───────────────────────────────────────────────────────────────────
// ── generic cache + fetch core (shared by both manifests) ───────────────────

interface CacheEnvelope {
interface CacheEnvelope<M> {
etag?: string;
cachedAt: string;
manifest: CommunityManifest;
manifest: M;
}

function cachePath(): string | null {
function cacheFilePath(file: string): string | null {
const dir = findUserDataDir();
return dir ? path.join(dir, CACHE_FILE) : null;
return dir ? path.join(dir, file) : null;
}

function readCache(): CacheEnvelope | null {
const p = cachePath();
function readCacheFile<M>(file: string, parse: (raw: unknown) => M): CacheEnvelope<M> | null {
const p = cacheFilePath(file);
if (!p || !fs.existsSync(p)) return null;
try {
const env = JSON.parse(fs.readFileSync(p, "utf8")) as CacheEnvelope;
const env = JSON.parse(fs.readFileSync(p, "utf8")) as CacheEnvelope<M>;
// Re-validate the cached manifest so a corrupted/older-shape file can't feed
// an invalid manifest downstream.
env.manifest = parseManifest(env.manifest);
env.manifest = parse(env.manifest);
return env;
} catch {
return null;
}
}

function writeCache(env: CacheEnvelope): void {
const p = cachePath();
function writeCacheFile<M>(file: string, env: CacheEnvelope<M>): void {
const p = cacheFilePath(file);
if (!p) return;
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
Expand All @@ -84,23 +101,32 @@ function writeCache(env: CacheEnvelope): void {
}
}

// ── fetch ─────────────────────────────────────────────────────────────────
interface FetchSpec<M> {
url: string;
file: string;
parse: (raw: unknown) => M;
/** Empty manifest returned when the network fails AND there is no cache. */
empty: M;
}

interface CoreResult<M> {
manifest: M;
fromCache: boolean;
cachedAt?: string;
error?: string;
}

/**
* Fetch the registry manifest.
*
* @param opts.force Skip the conditional-GET short-circuit and the "serve cache
* first" path — always hit the network (still falls back to
* cache on failure). Used by an explicit "Refresh".
* Generic cache-first fetch used by both the tools/commands manifest and the
* providers manifest. Serves the cache immediately (background-revalidating)
* unless force:true, uses a conditional GET via the stored ETag, and fails soft
* to the cached copy on any network/parse error.
*/
export async function fetchManifest(opts?: { force?: boolean }): Promise<RegistryFetchResult> {
const cache = readCache();
async function fetchGeneric<M>(spec: FetchSpec<M>, opts?: { force?: boolean }): Promise<CoreResult<M>> {
const cache = readCacheFile(spec.file, spec.parse);

// Cache-first (non-forced): serve the cache immediately when present. Callers
// wanting freshness pass force:true. This keeps the browse UI instant/offline.
if (cache && !opts?.force) {
// Kick a background revalidation but don't await it — return cache now.
void revalidate(cache);
void revalidateGeneric(spec, cache);
return { manifest: cache.manifest, fromCache: true, cachedAt: cache.cachedAt };
}

Expand All @@ -109,31 +135,32 @@ export async function fetchManifest(opts?: { force?: boolean }): Promise<Registr
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(MANIFEST_URL, {
// A forced Refresh is a HARD refresh: skip the conditional-GET ETag (so a
// stale CDN edge can't answer 304 with old content) and bust any HTTP/CDN
// cache with a cache-busting query param, so we always re-download.
const url = opts?.force ? `${spec.url}${spec.url.includes("?") ? "&" : "?"}_cb=${Date.now()}` : spec.url;
res = await fetch(url, {
signal: controller.signal,
headers: cache?.etag ? { "If-None-Match": cache.etag } : {},
headers: !opts?.force && cache?.etag ? { "If-None-Match": cache.etag } : {},
});
} finally {
clearTimeout(timer);
}

// 304 Not Modified → our cache is current.
if (res.status === 304 && cache) {
return { manifest: cache.manifest, fromCache: true, cachedAt: cache.cachedAt };
}
if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);

const json = (await res.json()) as unknown;
const manifest = parseManifest(json);
const env: CacheEnvelope = {
const manifest = spec.parse((await res.json()) as unknown);
const env: CacheEnvelope<M> = {
etag: res.headers.get("etag") ?? undefined,
cachedAt: new Date().toISOString(),
manifest,
};
writeCache(env);
writeCacheFile(spec.file, env);
return { manifest, fromCache: false, cachedAt: env.cachedAt };
} catch (err) {
// Network/parse failure → serve stale cache if we have one.
if (cache) {
return {
manifest: cache.manifest,
Expand All @@ -143,30 +170,30 @@ export async function fetchManifest(opts?: { force?: boolean }): Promise<Registr
};
}
return {
manifest: { version: 1, updatedAt: "", mcpServers: [], services: [], commands: [] },
manifest: spec.empty,
fromCache: false,
error: err instanceof Error ? err.message : String(err),
};
}
}

/** Background conditional GET that refreshes the cache without blocking a read. */
async function revalidate(cache: CacheEnvelope): Promise<void> {
async function revalidateGeneric<M>(spec: FetchSpec<M>, cache: CacheEnvelope<M>): Promise<void> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(MANIFEST_URL, {
res = await fetch(spec.url, {
signal: controller.signal,
headers: cache.etag ? { "If-None-Match": cache.etag } : {},
});
} finally {
clearTimeout(timer);
}
if (res.status === 304 || !res.ok) return;
const manifest = parseManifest((await res.json()) as unknown);
writeCache({
const manifest = spec.parse((await res.json()) as unknown);
writeCacheFile(spec.file, {
etag: res.headers.get("etag") ?? undefined,
cachedAt: new Date().toISOString(),
manifest,
Expand All @@ -176,10 +203,61 @@ async function revalidate(cache: CacheEnvelope): Promise<void> {
}
}

// ── tools/commands manifest ─────────────────────────────────────────────────

const MANIFEST_SPEC: FetchSpec<CommunityManifest> = {
url: MANIFEST_URL,
file: CACHE_FILE,
parse: parseManifest,
empty: { version: 1, updatedAt: "", mcpServers: [], services: [], commands: [] },
};

/**
* Fetch the registry manifest.
*
* @param opts.force Skip the conditional-GET short-circuit and the "serve cache
* first" path — always hit the network (still falls back to
* cache on failure). Used by an explicit "Refresh".
*/
export function fetchManifest(opts?: { force?: boolean }): Promise<RegistryFetchResult> {
return fetchGeneric(MANIFEST_SPEC, opts);
}

/** Force a network refresh (bypasses cache-first). Used by an explicit Refresh. */
export function refreshManifest(): Promise<RegistryFetchResult> {
return fetchManifest({ force: true });
}

// ── providers manifest ──────────────────────────────────────────────────────

const PROVIDERS_SPEC: FetchSpec<ProvidersManifest> = {
url: PROVIDERS_URL,
file: PROVIDERS_CACHE_FILE,
parse: parseProvidersManifest,
empty: { version: 1, updatedAt: "", providers: [] },
};

/** Fetch the community PROVIDERS manifest (cache-first; see fetchManifest). */
export function fetchProvidersManifest(opts?: { force?: boolean }): Promise<ProvidersFetchResult> {
return fetchGeneric(PROVIDERS_SPEC, opts);
}

/** Force a network refresh of the providers manifest. */
export function refreshProvidersManifest(): Promise<ProvidersFetchResult> {
return fetchProvidersManifest({ force: true });
}

/** Exposed for tests. */
export const __test = { MANIFEST_URL, CACHE_FILE, cachePath, readCache, writeCache };
export const __test = {
MANIFEST_URL,
PROVIDERS_URL,
CACHE_FILE,
PROVIDERS_CACHE_FILE,
cachePath: () => cacheFilePath(CACHE_FILE),
readCache: () => readCacheFile(CACHE_FILE, parseManifest),
writeCache: (env: CacheEnvelope<CommunityManifest>) => writeCacheFile(CACHE_FILE, env),
providersCachePath: () => cacheFilePath(PROVIDERS_CACHE_FILE),
readProvidersCache: () => readCacheFile(PROVIDERS_CACHE_FILE, parseProvidersManifest),
writeProvidersCache: (env: CacheEnvelope<ProvidersManifest>) =>
writeCacheFile(PROVIDERS_CACHE_FILE, env),
};
20 changes: 20 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ interface CommunityManifest {
interface RegistryFetchResult {
manifest: CommunityManifest; fromCache: boolean; cachedAt?: string; error?: string;
}
// Canonical, Zod-validated definitions live in shared/chat/registry-schema.ts.
// These interfaces are hand-mirrored here (like the MCP/service/command ones
// above) because preload is a separate esbuild target that can't import the
// shared module's runtime; keep them in sync with the shared source.
interface RegistryProviderEntry extends RegistryEntryMeta {
definition: {
name: string; baseUrl: string; defaultModel?: string; needsApiKey: boolean;
apiKeyUrl?: string; models?: string[];
};
}
interface ProvidersManifest {
version: number; updatedAt: string; providers: RegistryProviderEntry[];
}
interface ProvidersFetchResult {
manifest: ProvidersManifest; fromCache: boolean; cachedAt?: string; error?: string;
}
// ── Inline types for the codebase index / Architecture tab ──────────────────
interface CodebaseSymbol {
id: string; file_id: string; name: string; kind: string; line: number;
Expand Down Expand Up @@ -711,6 +727,10 @@ const api = {
fetch: () => invoke<RegistryFetchResult>("registry:fetch"),
/** Force a network refresh (explicit Refresh button). */
refresh: () => invoke<RegistryFetchResult>("registry:refresh"),
/** Community AI providers (separate providers.json manifest). Cache-first. */
fetchProviders: () => invoke<ProvidersFetchResult>("registry:fetchProviders"),
/** Force a network refresh of the providers manifest. */
refreshProviders: () => invoke<ProvidersFetchResult>("registry:refreshProviders"),
},

// ── Git operations (Agent Git tab) ────────────
Expand Down
Loading