Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f634027
feat(mobile): browse & install community AI providers
ddutchie Aug 1, 2026
abfa8ee
feat(ai): provider credit balances and per-call cost visibility
ddutchie Aug 2, 2026
424d3ce
fix(mobile): address PR review — registry fetch guards, install dedup…
ddutchie Aug 2, 2026
8edd856
fix(ci): unused test imports + live-check skip when sibling repo absent
ddutchie Aug 2, 2026
b741548
fix(ai): PR review — costUsd optional/validated, subagent cost, tiny-…
ddutchie Aug 2, 2026
359f805
feat(ai): provider balance in the chat usage ring + portal chat-setti…
ddutchie Aug 2, 2026
3d4010e
feat(ai): default model is now gpt-5.6-luna (desktop + mobile)
ddutchie Aug 2, 2026
7113cb1
fix(ai): address PR review — ring balance cache keys, shared formatte…
ddutchie Aug 2, 2026
4139f6c
feat(ai): per-model cost, provider logos & image gating from models.dev
ddutchie Aug 2, 2026
e856132
feat(ai): show model input-capability chips (T/I/PDF/V/A) in pickers
ddutchie Aug 2, 2026
4e4b428
fix(ai): crash on legacy catalog cache + icon capability chips
ddutchie Aug 2, 2026
b8ee5cb
feat(ai): attach PDFs to chat messages (document parts + rasterized f…
ddutchie Aug 2, 2026
cee5385
fix(ai): PR review — negative-cost formatting + catalog-aware image g…
ddutchie Aug 2, 2026
61a9704
feat(ai, mobile): model dropdown + auto-fetch model list like desktop
ddutchie Aug 2, 2026
3cb6ef1
feat(ai, mobile): favorite models in the model dropdown like desktop
ddutchie Aug 2, 2026
90f0fa8
fix(ai): resolve model logos from models.dev canonical owner
ddutchie Aug 2, 2026
e9a68f5
feat(settings): brand marks on saved providers + URL fallback match
ddutchie Aug 2, 2026
5024ddd
feat(settings): fall back to models.dev logos for direct-vendor endpo…
ddutchie Aug 2, 2026
17ff3fb
fix(mobile+desktop): render models.dev logos through ConnectorLogo chip
ddutchie Aug 2, 2026
2f0bef1
feat(mobile): edit provider on demand + hide Browse in Apple/Rork mode
ddutchie Aug 2, 2026
57fc792
fix: address PR #108 review comments
ddutchie Aug 2, 2026
59a66db
fix(lint): use const for logoSvgSubscribers (never reassigned)
ddutchie Aug 2, 2026
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
16 changes: 16 additions & 0 deletions changelogs/v2.5.20.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## Features

- **Remaining-credit badges now work beyond OpenRouter.** The settings credit lookup previously probed a single OpenRouter-style `GET {base}/v1/key` endpoint, so only OpenRouter showed a balance. Community providers can now advertise a `credits` descriptor (a dedicated HTTPS endpoint + response shape) in the `providers.json` catalog, and the main process looks that up for the active endpoint and parses the reply accordingly. DeepSeek (via `GET /user/balance`, with its USD/CNY balance shown in the right currency) and OpenAI (via the legacy `credit_grants` endpoint) ship with descriptors; any provider that doesn't expose a balance API still gracefully hides the display.
- **Neuralwatt now shows a live balance too.** Its catalog entry gained a `credits` descriptor pointing at `GET /v1/quota`, and the parser reads the energy account's `credits_remaining_usd` / `total_credits_usd` / `credits_used_usd`. The credit-parsing logic moved into a small shared module, and the `cairn-community` repo gained a live endpoint test (`npm run test:credits`) that exercises every provider's balance endpoint against real keys (set them in `.env.test`).
- **Per-turn cost in the context ring.** Providers that report the USD cost of a call in their usage (`usage.cost` — Neuralwatt's energy accounting does) now surface that figure in the context-ring popover, alongside the token breakdown, so you can see what each message cost.
- **Account balance in the context ring.** The ring's popover now also shows a **Balance** row with your remaining credits for the active provider (`$5.42 of $45.5`, or "Free tier" for no-cost providers), refreshed at most once a minute so repeated ring instances share a single probe. It hides when the provider exposes no balance or you're offline, and is skipped on per-subagent rings (it's account-level, not per-turn).
- **New default model.** Fresh installs, resets, and unset-model fallbacks now use **`gpt-5.6-luna`** instead of `gpt-4o`/`gpt-4o-mini` as the default for chat, coding agents, notes AI actions, and flow summaries (the model picker still lists the endpoint's real models).
- **Model pickers now show cost and provider logos.** Every model dropdown (chat quick-settings, endpoint rows, saved-provider form) pulls live per-model data from the public models.dev catalog: the provider logo sits next to the model name, a compact **`$in/$out`** cost label shows the per-1M-token price (or "free"), and a warning triangle marks models known not to support tool calls. The same info appears on the closed trigger button, and logos adapt to the theme (light glyph on dark, dark on light).
- **Provider logos now match the model's own brand.** models.dev lists each model under every host that serves it, so the logo used to be whoever came first in the catalog (e.g. `deepseek/deepseek-v4-flash` could show an HPC AI or Cortecs glyph instead of DeepSeek's). Cairn now reads models.dev's canonical ownership list (`models.json`, keyed by `<provider>/<model>` id) and uses the model's actual owner for the icon — falling back to a brand-name heuristic and finally the catalog entry. Correct logos now appear for long-tail labs too (GLM, Grok, Qwen, Gemma, and more), even before the full catalog has loaded.
- **Image attachments are gated per model.** The chat composer's attach-image button is hidden when the selected model is known (via models.dev) not to accept image input; models we can't look up stay permissive. Pasting images is ignored too. Everything falls back gracefully offline or before the catalog loads.
- **Attach PDFs to chat messages.** The chat composer now accepts PDF files alongside images. Models known (via models.dev) to take PDF input receive the raw bytes as an Anthropic-style `document` part — lossless, and smaller for text-heavy files. Models that only take images get the PDF rasterized (pdf.js, up to 8 pages) into image parts instead, so the file still arrives. Attachments show as a PDF chip in the composer and the sent message, and the context-ring token count uses a size-based estimate for document parts rather than raw base64 length.

## Fixes

- **Chat settings no longer clip in narrow windows.** The in-chat quick-settings dropdown used inline positioning inside the chat panel's overflow container, so in a narrow window (or a docked panel) it could get cut off at the panel edge. It's now a portaled popover, so it always floats above the window edge.

57 changes: 19 additions & 38 deletions electron/ipc/ai-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { generatePrd } from "../lib/prd";
import { callLLM, isLocalEndpoint, normaliseBaseUrl, buildApiUrl, type LLMConfig } from "../lib/llm";
import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache";
import { resolveLlmApiKey } from "../lib/secure-store";
import { fetchProvidersManifest } from "../lib/community-registry";
import { resolveCreditSpec, probeCredits } from "../lib/provider-credits";

function resolveConfig(
config: { baseUrl?: string; model?: string; apiKey?: string } | undefined,
Expand All @@ -29,7 +31,7 @@ function resolveConfig(
};

const baseUrl = normaliseBaseUrl(mergedConfig.baseUrl || "https://api.openai.com");
const model = mergedConfig.model || "gpt-4o-mini";
const model = mergedConfig.model || "gpt-5.6-luna";
const keyRef = mergedConfig.apiKey || "";
const isLocal = isLocalEndpoint(baseUrl);
if (!keyRef && !isLocal) {
Expand Down Expand Up @@ -131,54 +133,33 @@ export function registerAiHandlers(ctx: DbContext): void {

// ── Provider credits / key info ──────────────────
// Best-effort lookup of remaining credits for providers that expose it.
// OpenRouter returns account/key balance at GET {base}/v1/key as
// Default probe: OpenRouter-style GET {base}/v1/key returning
// { data: { limit, limit_remaining, usage, is_free_tier } } (credits are USD).
// Returns null when the provider doesn't implement it (any non-2xx / parse
// failure) so the caller can simply hide the display — never throws.
// Providers that expose balance off the chat base (DeepSeek, OpenAI's legacy
// credit_grants endpoint) advertise a `credits` descriptor in the community
// providers.json manifest — the handler looks that up by endpoint, then parses
// the response per its `shape`. Returns null when the provider doesn't expose
// credits (any non-2xx / parse failure) so the caller can hide the display —
// never throws.
registerIpcHandle(
"ai:fetchKeyInfo",
async (_e, args: { baseUrl?: string; apiKey?: string }) =>
handle(async () => {
const url = normaliseBaseUrl(args.baseUrl || "https://api.openai.com");
const baseUrl = normaliseBaseUrl(args.baseUrl || "https://api.openai.com");
const realKey = resolveLlmApiKey(args.apiKey);
// No key → nothing to query (credit endpoints are all authed).
if (!realKey) return null;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 12_000);
try {
const res = await fetch(buildApiUrl(url, "key"), {
headers: { Authorization: `Bearer ${realKey}` },
signal: ac.signal,
});
if (!res.ok) return null;
const json = (await res.json()) as {
data?: {
limit?: number | null;
limit_remaining?: number | null;
usage?: number | null;
is_free_tier?: boolean;
};
};
const d = json?.data;
if (!d || typeof d !== "object") return null;
const usage = typeof d.usage === "number" ? d.usage : null;
const limit = typeof d.limit === "number" ? d.limit : null;
// Prefer the explicit remaining figure; else derive from limit - usage.
const remaining =
typeof d.limit_remaining === "number"
? d.limit_remaining
: limit != null && usage != null
? limit - usage
: null;
// Nothing usable to show.
if (remaining == null && usage == null && limit == null) return null;
return {
remaining,
usage,
limit,
isFreeTier: d.is_free_tier ?? null,
currency: "USD" as const,
};
const { manifest } = await fetchProvidersManifest();
const credits = resolveCreditSpec(baseUrl, manifest.providers);
// Explicit descriptor wins; otherwise fall back to the OpenRouter-style
// /v1/key probe (harmless for providers without one — returns null).
const url = credits?.url ?? buildApiUrl(baseUrl, "key");
const shape = credits?.shape ?? "openrouter";
const probe = await probeCredits(url, realKey, shape, ac.signal);
return probe.info;
} catch {
// Endpoint absent / offline / aborted — treat as "no credits info".
return null;
Expand Down
50 changes: 35 additions & 15 deletions electron/ipc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { getExternalToolDefs } from "../lib/external-tools";
import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache";
import { runToolLoop } from "../lib/chat-loop";
import { resolveLlmApiKey } from "../lib/secure-store";
import { buildAttachmentParts } from "../../shared/models/pdf-attach";

// Track one AbortController per renderer webContents ID
const abortControllers = new Map<number, AbortController>();
Expand Down Expand Up @@ -54,7 +55,7 @@ function resolveAIConfig(config?: {
return {
provider: reqConfig?.provider ?? "openai",
baseUrl: normaliseBaseUrl(reqConfig?.baseUrl ?? "https://api.openai.com"),
model: reqConfig?.model ?? "gpt-4o-mini",
model: reqConfig?.model ?? "gpt-5.6-luna",
// Resolve the keychain ref (or pass a literal through) to the real key used
// for this request only. Never stored anywhere from here on.
apiKey: resolveLlmApiKey(reqConfig?.apiKey),
Expand Down Expand Up @@ -135,19 +136,17 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
return;
}

// Images are always passed through to the model. We can't reliably guess
// vision support from a model id — cloud APIs, custom OpenAI-compatible
// endpoints, and on-device models (some, like Gemma, DO support vision)
// all expose arbitrary names. So we forward the images and let the model /
// endpoint decide; a non-vision model will simply say it can't see them.
// Attachments are forwarded to the model: images as standard image_url
// parts, PDFs as Anthropic-style `document` base64 parts (built by the
// shared helper). We can't reliably guess vision/pdf support from a model
// id — cloud APIs, custom OpenAI-compatible endpoints, and on-device models
// all expose arbitrary names. The renderer gates what may attach (it knows
// the catalog), so a part that arrives here is meant for this model / endpoint.

const userMessage: OpenAIMessage = req.images?.length
? ({
role: "user",
content: [
{ type: "text", text: req.message },
...req.images.map((img) => ({ type: "image_url", image_url: { url: img.dataUrl } })),
],
content: buildAttachmentParts(req.message, req.images),
} as unknown as OpenAIMessage)
: { role: "user", content: req.message };

Expand All @@ -174,6 +173,7 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
let promptTokens = 0;
let completionTokens = 0;
let reasoningTokens = 0;
let costUsd: number | undefined = undefined;
let lastBreakdown: TokenBreakdown | undefined = undefined;

// Assemble external tool defs (MCP servers + custom services) in scope for
Expand All @@ -186,19 +186,39 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
}
const allTools = externalDefs.length > 0 ? [...TOOLS, ...externalDefs] : TOOLS;

const addUsage = (pt: number, ct: number, rt?: number) => {
const addUsage = (pt: number, ct: number, rt?: number, cost?: number) => {
promptTokens = pt;
completionTokens += ct;
if (typeof rt === "number") reasoningTokens += rt;
// Provider-reported USD cost of this call (e.g. Neuralwatt usage.cost);
// accumulated across tool-loop rounds like completion tokens. Only
// non-negative finite values are accepted; an explicitly reported 0 is
// preserved, and nothing is set when the provider reports no cost.
if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0) {
costUsd = (costUsd ?? 0) + cost;
}
try {
const rawBreakdown = calculatePromptBreakdown(buildSystemPrompt(req), messages, allTools);
lastBreakdown = scaleBreakdown(rawBreakdown, promptTokens);
} catch (err) {
console.error("[chat] failed to calculate breakdown:", err);
}
send("chat:usage", { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown });
send("chat:usage", { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown, costUsd });
};

// Final usage object attached to chat:done (or undefined when nothing ran —
// a cost-only report is still retained, not dropped for having no tokens).
const finalUsage = () =>
promptTokens > 0 || costUsd != null
? {
promptTokens,
completionTokens,
reasoningTokens,
breakdown: lastBreakdown,
costUsd: costUsd != null ? costUsd : undefined,
}
: undefined;

// ── Subagent mode ─────────────────────────────────────────────────────────
// Per-thread toggle routes the turn through the dispatch → research/write
// loop. It streams a live, expandable subagent trace to the renderer via
Expand Down Expand Up @@ -243,7 +263,7 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
content: abortCtrl.signal.aborted ? "" : dispatchResult.content,
reasoning: dispatchResult.reasoning,
contextRefs: [],
usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined,
usage: finalUsage(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
} catch (err) {
// Cancellation or an unexpected failure must never leave the input
Expand Down Expand Up @@ -284,10 +304,10 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
}

if (abortCtrl.signal.aborted) {
send("chat:done", { content: "", reasoning: loopResult.reasoning, contextRefs: [], usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined });
send("chat:done", { content: "", reasoning: loopResult.reasoning, contextRefs: [], usage: finalUsage() });
return;
}

send("chat:done", { content: loopResult.content, reasoning: loopResult.reasoning, contextRefs: [], usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined });
send("chat:done", { content: loopResult.content, reasoning: loopResult.reasoning, contextRefs: [], usage: finalUsage() });
});
}
2 changes: 1 addition & 1 deletion electron/ipc/flow-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function resolveAiConfig(input: { baseUrl?: string; model?: string; apiKey?: str
}

const baseUrl = normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com");
const model = reqConfig?.model || "gpt-4o-mini";
const model = reqConfig?.model || "gpt-5.6-luna";
const keyRef = reqConfig?.apiKey || "";
const isLocal = isLocalEndpoint(baseUrl);
if (!keyRef && !isLocal) {
Expand Down
6 changes: 3 additions & 3 deletions electron/ipc/pi-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export function registerPiAgentHandler(

const llmConfig: AgentLLMConfig = {
baseUrl: normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com"),
model: reqConfig?.model || "gpt-4o",
model: reqConfig?.model || "gpt-5.6-luna",
apiKey: resolveLlmApiKey(reqConfig?.apiKey),
maxSteps: reqConfig?.maxSteps ?? 20,
temperature: reqConfig?.temperature ?? 0.3,
Expand Down Expand Up @@ -317,7 +317,7 @@ export function registerPiAgentHandler(

const llmConfig: AgentLLMConfig = {
baseUrl: normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com"),
model: reqConfig?.model || "gpt-4o",
model: reqConfig?.model || "gpt-5.6-luna",
apiKey: resolveLlmApiKey(reqConfig?.apiKey),
maxSteps: reqConfig?.maxSteps ?? 20,
temperature: reqConfig?.temperature ?? 0.3,
Expand Down Expand Up @@ -390,7 +390,7 @@ export function registerPiAgentHandler(

const llmConfig: AgentLLMConfig = {
baseUrl: normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com"),
model: reqConfig?.model || "gpt-4o",
model: reqConfig?.model || "gpt-5.6-luna",
apiKey: resolveLlmApiKey(reqConfig?.apiKey),
maxSteps: 20,
temperature: 0.1,
Expand Down
2 changes: 1 addition & 1 deletion electron/ipc/tool-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function resolveConfig(): AIConfig {
return {
provider: cached?.provider ?? "openai",
baseUrl: normaliseBaseUrl(cached?.baseUrl ?? "https://api.openai.com"),
model: cached?.model ?? "gpt-4o-mini",
model: cached?.model ?? "gpt-5.6-luna",
// The cached apiKey is a `secret://llm:<providerId>/apiKey` reference since the
// v2.5.9 keychain migration — resolve it to the real key here (same as chat.ts /
// pi-agent.ts). Sending the raw ref as a bearer token 401s the provider.
Expand Down
5 changes: 4 additions & 1 deletion electron/lib/chat-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function runToolLoop(
signal?: AbortSignal,
getWin?: () => BrowserWindow | null,
provider?: string,
onUsage?: (pt: number, ct: number, rt?: number) => void,
onUsage?: (pt: number, ct: number, rt?: number, costUsd?: number) => void,
emitToolCallDone?: (e: { tool: string; cairnRef?: { type: "note" | "task"; id: string; title: string }; externalRef?: { url: string; title?: string; snippet?: string }; output?: string; callId?: string; ok?: boolean; error?: string }) => void,
onToken?: (delta: string) => void,
onThought?: (delta: string) => void,
Expand Down Expand Up @@ -81,10 +81,12 @@ export async function runToolLoop(
const choice = res.choices?.[0];
if (!choice) return { exhausted: true, content: "No response from local Llama on-device model.", reasoning: "" };
if (res.usage && onUsage) {
const usageCost = (res.usage as { cost?: unknown }).cost;
onUsage(
res.usage.prompt_tokens ?? 0,
res.usage.completion_tokens ?? 0,
res.usage.completion_tokens_details?.reasoning_tokens ?? 0,
typeof usageCost === "number" ? usageCost : undefined,
);
}
const rawMsg = choice.message as OpenAIMessage & { reasoning?: string; reasoning_content?: string };
Expand Down Expand Up @@ -178,6 +180,7 @@ export async function runToolLoop(
chunk.usage.prompt_tokens ?? 0,
chunk.usage.completion_tokens ?? 0,
chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0,
typeof chunk.usage.cost === "number" ? chunk.usage.cost : undefined,
);
}
const delta = chunk.choices?.[0]?.delta;
Expand Down
Loading