summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 08:24:40 +0900
committerAdam Malczewski <[email protected]>2026-06-03 08:24:40 +0900
commitbc3ecbe7b72f6da6ed36d0cea5a66de1c440269a (patch)
tree17e84ebf8d83c51a7a50312c256372a86e38b92a /packages/core/src
parentb26821ead97b986f886065b20d3dbde8283daa64 (diff)
parentae672fd4f5542a2c217cf97657bf81eeebdaabbd (diff)
downloaddispatch-bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a.tar.gz
dispatch-bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a.zip
Merge branch 'dev' into cmp7/compaction-tool
# Conflicts: # packages/frontend/src/lib/components/ChatInput.svelte
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/agent/agent.tsbin57822 -> 60515 bytes
-rw-r--r--packages/core/src/credentials/claude.ts69
-rw-r--r--packages/core/src/credentials/index.ts2
-rw-r--r--packages/core/src/index.ts18
-rw-r--r--packages/core/src/models/attachments.ts151
-rw-r--r--packages/core/src/models/catalog.ts50
-rw-r--r--packages/core/src/models/index.ts19
-rw-r--r--packages/core/src/tools/key-usage.ts322
-rw-r--r--packages/core/src/tools/summon.ts1
-rw-r--r--packages/core/src/types/index.ts49
10 files changed, 670 insertions, 11 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 4bfa7eb..08b317a 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
Binary files differ
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 7818222..050a0fc 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -441,6 +441,22 @@ export interface ClaudeUsageReport {
orgId?: string;
}
+/**
+ * A usage report paired with provenance: whether it came back from a fresh
+ * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served
+ * from the local `usage_cache` table after a failed/skipped live fetch.
+ *
+ * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when
+ * that cached payload was last fetched FROM the source (the `usage_cache.cached_at`
+ * column). `source: "live"` omits `cachedAt` (the data is current as of now).
+ */
+export interface ClaudeUsageResult {
+ report: ClaudeUsageReport;
+ source: "live" | "cache";
+ /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */
+ cachedAt?: number;
+}
+
// ─── Well-known Anthropic models ──────────────────────────────
/**
@@ -602,14 +618,23 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
}
}
-function getCachedUsage(keyId: string): ClaudeUsageReport | null {
+/**
+ * Read a cached usage report plus the epoch-ms it was last fetched from source.
+ * Returns `null` when there is no cached row (or on any DB/parse error).
+ */
+function getCachedUsageWithMeta(
+ keyId: string,
+): { report: ClaudeUsageReport; cachedAt: number } | null {
try {
const db = getDatabase();
const row = db
- .query("SELECT report_json FROM usage_cache WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { report_json: string } | null;
+ .query("SELECT report_json, cached_at FROM usage_cache WHERE key_id = $keyId")
+ .get({ $keyId: keyId }) as { report_json: string; cached_at: number } | null;
if (!row) return null;
- return JSON.parse(row.report_json) as ClaudeUsageReport;
+ return {
+ report: JSON.parse(row.report_json) as ClaudeUsageReport,
+ cachedAt: row.cached_at,
+ };
} catch {
return null;
}
@@ -635,13 +660,35 @@ function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageRepo
}
}
-export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
+/**
+ * Fetch an account's usage report along with its provenance (live vs cache).
+ *
+ * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint;
+ * on success the fresh report is cached and returned as `source: "live"`. If
+ * credentials cannot be refreshed OR the live fetch returns nothing, fall back
+ * to the local `usage_cache` row and return it as `source: "cache"` with the
+ * `cachedAt` timestamp recording when that payload was last fetched from source.
+ * Returns `null` only when neither a live report nor a cached row is available.
+ */
+export async function getAccountUsageWithSource(
+ account: ClaudeAccount,
+): Promise<ClaudeUsageResult | null> {
const creds = await refreshAccountCredentialsAsync(account);
- if (!creds) return getCachedUsage(account.id);
- const report = await fetchClaudeUsage(creds.accessToken);
- if (report) {
- setCachedUsage(account.id, "anthropic", report);
- return report;
+ if (creds) {
+ const report = await fetchClaudeUsage(creds.accessToken);
+ if (report) {
+ setCachedUsage(account.id, "anthropic", report);
+ return { report, source: "live" };
+ }
}
- return getCachedUsage(account.id);
+ const cached = getCachedUsageWithMeta(account.id);
+ if (cached) {
+ return { report: cached.report, source: "cache", cachedAt: cached.cachedAt };
+ }
+ return null;
+}
+
+export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
+ const result = await getAccountUsageWithSource(account);
+ return result?.report ?? null;
}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index 5221dc6..131f035 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -15,9 +15,11 @@ export {
type ClaudeProfile,
type ClaudeUsageBucket,
type ClaudeUsageReport,
+ type ClaudeUsageResult,
discoverClaudeAccounts,
fetchAnthropicModels,
getAccountUsage,
+ getAccountUsageWithSource,
getAnthropicBetas,
getAnthropicHeaders,
getClaudeAccountsFromDB,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 2789b2c..25cc909 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -98,9 +98,26 @@ export {
} from "./lsp/index.js";
// Models
export {
+ ACCEPTED_ATTACHMENT_MEDIA_TYPES,
+ ACCEPTED_IMAGE_MEDIA_TYPES,
+ ACCEPTED_PDF_MEDIA_TYPE,
+ type AttachmentValidationError,
+ type AttachmentValidationResult,
+ base64ByteLength,
getModelsCatalog,
+ hasAttachments,
+ isAcceptedAttachmentMediaType,
+ isImageMediaType,
+ isPdfMediaType,
+ MAX_ATTACHMENTS,
+ MAX_IMAGE_BYTES,
+ MAX_PDF_BYTES,
+ MAX_TOTAL_ATTACHMENT_BYTES,
+ type ModelInputCapabilities,
ModelRegistry,
resolveContextLimit,
+ resolveModelCapabilities,
+ validateUserContent,
} from "./models/index.js";
// Notifications (ntfy.sh)
export * from "./notifications/index.js";
@@ -115,6 +132,7 @@ export {
} from "./skills/index.js";
export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
// Tools
+export { createKeyUsageTool, type KeyUsageCallbacks } from "./tools/key-usage.js";
export { createListFilesTool } from "./tools/list-files.js";
export { createLspTool, type LspToolContext } from "./tools/lsp.js";
export { createReadFileTool } from "./tools/read-file.js";
diff --git a/packages/core/src/models/attachments.ts b/packages/core/src/models/attachments.ts
new file mode 100644
index 0000000..5c98db4
--- /dev/null
+++ b/packages/core/src/models/attachments.ts
@@ -0,0 +1,151 @@
+// Validation + limits for multimodal user attachments (images / PDFs).
+//
+// Kept dependency-free (no DB / `bun:sqlite` import) so both the API layer
+// (`/chat` request validation) and any future caller can share the exact same
+// allowlist and size/count ceilings. The limits mirror Anthropic's documented
+// vision/PDF API constraints (the only image-capable providers Dispatch maps),
+// so a request that passes here won't be rejected by the provider for size.
+
+import type { UserAttachmentPart, UserContentPart } from "../types/index.js";
+
+/** Accepted image media types. */
+export const ACCEPTED_IMAGE_MEDIA_TYPES = [
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "image/gif",
+] as const;
+
+/** Accepted document media types. */
+export const ACCEPTED_PDF_MEDIA_TYPE = "application/pdf";
+
+/** Every media type we accept as an attachment. */
+export const ACCEPTED_ATTACHMENT_MEDIA_TYPES = [
+ ...ACCEPTED_IMAGE_MEDIA_TYPES,
+ ACCEPTED_PDF_MEDIA_TYPE,
+] as const;
+
+/** Per-image byte ceiling (Anthropic: 5 MB/image). */
+export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
+
+/** Per-PDF byte ceiling (Anthropic: 32 MB/PDF). */
+export const MAX_PDF_BYTES = 32 * 1024 * 1024;
+
+/** Max attachments per message (Anthropic: 20 images/request). */
+export const MAX_ATTACHMENTS = 20;
+
+/**
+ * Total attachment payload ceiling for a single request (decoded bytes). Bounds
+ * the overall request size even when each individual file is within its limit.
+ */
+export const MAX_TOTAL_ATTACHMENT_BYTES = 32 * 1024 * 1024;
+
+/** Whether a media type is an accepted image type. */
+export function isImageMediaType(mediaType: string): boolean {
+ return (ACCEPTED_IMAGE_MEDIA_TYPES as readonly string[]).includes(mediaType);
+}
+
+/** Whether a media type is the accepted PDF type. */
+export function isPdfMediaType(mediaType: string): boolean {
+ return mediaType === ACCEPTED_PDF_MEDIA_TYPE;
+}
+
+/** Whether a media type is an accepted attachment type at all. */
+export function isAcceptedAttachmentMediaType(mediaType: string): boolean {
+ return (ACCEPTED_ATTACHMENT_MEDIA_TYPES as readonly string[]).includes(mediaType);
+}
+
+/**
+ * Decoded byte length of a base64 string, computed WITHOUT allocating the
+ * decoded buffer. Tolerates an optional `data:<mediaType>;base64,` prefix and
+ * any embedded whitespace/newlines. Returns 0 for an empty/whitespace string.
+ */
+export function base64ByteLength(b64: string): number {
+ // Strip a data-URI prefix if present.
+ const comma = b64.indexOf(",");
+ const body = b64.startsWith("data:") && comma !== -1 ? b64.slice(comma + 1) : b64;
+ let len = 0;
+ let pad = 0;
+ for (let i = 0; i < body.length; i++) {
+ const ch = body.charCodeAt(i);
+ // Skip whitespace (space, \t, \n, \r).
+ if (ch === 32 || ch === 9 || ch === 10 || ch === 13) continue;
+ len++;
+ if (body[i] === "=") pad++;
+ }
+ if (len === 0) return 0;
+ // 4 base64 chars → 3 bytes, minus padding.
+ return Math.floor((len * 3) / 4) - pad;
+}
+
+export type AttachmentValidationError =
+ | { code: "unsupported-type"; mediaType: string }
+ | { code: "image-too-large"; mediaType: string; bytes: number; limit: number }
+ | { code: "pdf-too-large"; bytes: number; limit: number }
+ | { code: "too-many"; count: number; limit: number }
+ | { code: "total-too-large"; bytes: number; limit: number }
+ | { code: "empty"; mediaType: string };
+
+export interface AttachmentValidationResult {
+ ok: boolean;
+ errors: AttachmentValidationError[];
+}
+
+/** Extract just the attachment parts from a mixed content list. */
+function attachmentsOf(content: UserContentPart[]): UserAttachmentPart[] {
+ return content.filter((p): p is UserAttachmentPart => p.type === "attachment");
+}
+
+/**
+ * Validate the attachments in a multimodal user content list against the
+ * media-type allowlist and the size/count ceilings. Pure: never throws,
+ * collects every violation so the caller can report them all at once.
+ *
+ * Text parts are ignored (always valid). An empty content list is valid (it's
+ * just a text-only message expressed as parts).
+ */
+export function validateUserContent(content: UserContentPart[]): AttachmentValidationResult {
+ const errors: AttachmentValidationError[] = [];
+ const attachments = attachmentsOf(content);
+
+ if (attachments.length > MAX_ATTACHMENTS) {
+ errors.push({ code: "too-many", count: attachments.length, limit: MAX_ATTACHMENTS });
+ }
+
+ let total = 0;
+ for (const att of attachments) {
+ if (!isAcceptedAttachmentMediaType(att.mediaType)) {
+ errors.push({ code: "unsupported-type", mediaType: att.mediaType });
+ continue;
+ }
+ const bytes = base64ByteLength(att.data);
+ total += bytes;
+ if (bytes === 0) {
+ errors.push({ code: "empty", mediaType: att.mediaType });
+ continue;
+ }
+ if (isPdfMediaType(att.mediaType)) {
+ if (bytes > MAX_PDF_BYTES) {
+ errors.push({ code: "pdf-too-large", bytes, limit: MAX_PDF_BYTES });
+ }
+ } else if (bytes > MAX_IMAGE_BYTES) {
+ errors.push({
+ code: "image-too-large",
+ mediaType: att.mediaType,
+ bytes,
+ limit: MAX_IMAGE_BYTES,
+ });
+ }
+ }
+
+ if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
+ errors.push({ code: "total-too-large", bytes: total, limit: MAX_TOTAL_ATTACHMENT_BYTES });
+ }
+
+ return { ok: errors.length === 0, errors };
+}
+
+/** Convenience: does the content list contain at least one attachment? */
+export function hasAttachments(content: UserContentPart[] | undefined | null): boolean {
+ return !!content && content.some((p) => p.type === "attachment");
+}
diff --git a/packages/core/src/models/catalog.ts b/packages/core/src/models/catalog.ts
index dea4647..ac310b1 100644
--- a/packages/core/src/models/catalog.ts
+++ b/packages/core/src/models/catalog.ts
@@ -18,6 +18,15 @@ interface ModelsDevModel {
context?: number;
output?: number;
};
+ /**
+ * Input/output modalities the model accepts. We read `input` to decide
+ * whether the model can take image / pdf attachments. Absent on older
+ * catalog entries — treated as "unknown" (capability resolves to `null`).
+ */
+ modalities?: {
+ input?: string[];
+ output?: string[];
+ };
}
interface ModelsDevProvider {
@@ -172,6 +181,47 @@ export async function resolveContextLimit(
return null;
}
+/**
+ * Image / PDF input capabilities for a model, resolved from the models.dev
+ * catalog's `modalities.input` list.
+ */
+export interface ModelInputCapabilities {
+ /** Model accepts image input (vision). */
+ image: boolean;
+ /** Model accepts PDF/document input. */
+ pdf: boolean;
+}
+
+/**
+ * Resolve whether a model accepts image / pdf input for the given Dispatch
+ * provider + model id. Returns `null` when the capability is UNKNOWN — i.e. the
+ * provider is unsupported/unmapped, the model is absent from the catalog, the
+ * entry predates the `modalities` field, or the catalog is unavailable. Callers
+ * should treat `null` as "can't verify" (optimistic allow) rather than a
+ * definitive "no", so a temporary catalog outage never disables a known-good
+ * vision model.
+ *
+ * A non-null result means the catalog DID describe the model's input modalities
+ * — `{ image, pdf }` then reflects exactly what it advertises (a definitive
+ * yes/no for each).
+ */
+export async function resolveModelCapabilities(
+ provider: string,
+ modelId: string,
+): Promise<ModelInputCapabilities | null> {
+ const candidates = PROVIDER_MAP[provider];
+ if (!candidates || !modelId) return null;
+
+ const catalog = await getModelsCatalog();
+ for (const providerId of candidates) {
+ const input = catalog[providerId]?.models?.[modelId]?.modalities?.input;
+ if (Array.isArray(input)) {
+ return { image: input.includes("image"), pdf: input.includes("pdf") };
+ }
+ }
+ return null;
+}
+
/** Test-only: reset the in-process memo so a test can re-exercise loading. */
export function __resetCatalogCacheForTests(): void {
cached = null;
diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts
index 2fcd657..15d1ee2 100644
--- a/packages/core/src/models/index.ts
+++ b/packages/core/src/models/index.ts
@@ -1,5 +1,24 @@
export {
+ ACCEPTED_ATTACHMENT_MEDIA_TYPES,
+ ACCEPTED_IMAGE_MEDIA_TYPES,
+ ACCEPTED_PDF_MEDIA_TYPE,
+ type AttachmentValidationError,
+ type AttachmentValidationResult,
+ base64ByteLength,
+ hasAttachments,
+ isAcceptedAttachmentMediaType,
+ isImageMediaType,
+ isPdfMediaType,
+ MAX_ATTACHMENTS,
+ MAX_IMAGE_BYTES,
+ MAX_PDF_BYTES,
+ MAX_TOTAL_ATTACHMENT_BYTES,
+ validateUserContent,
+} from "./attachments.js";
+export {
getModelsCatalog,
+ type ModelInputCapabilities,
resolveContextLimit,
+ resolveModelCapabilities,
} from "./catalog.js";
export { ModelRegistry } from "./registry.js";
diff --git a/packages/core/src/tools/key-usage.ts b/packages/core/src/tools/key-usage.ts
new file mode 100644
index 0000000..0655ad7
--- /dev/null
+++ b/packages/core/src/tools/key-usage.ts
@@ -0,0 +1,322 @@
+import { z } from "zod";
+import type { ClaudeAccount, ClaudeUsageReport, ClaudeUsageResult } from "../credentials/claude.js";
+import { getAccountUsageWithSource } from "../credentials/claude.js";
+import type { OpencodeUsageReport } from "../credentials/opencode.js";
+import { fetchOpencodeUsage as defaultFetchOpencodeUsage } from "../credentials/opencode.js";
+import type { KeyState, ToolDefinition } from "../types/index.js";
+
+/**
+ * Collaborators the `key_usage` tool needs from the API layer (which owns the
+ * live `ModelRegistry` and the discovered Claude accounts). The two `fetch*`
+ * hooks default to the real credential fetchers but are injectable so tests can
+ * exercise the tool without network or DB access.
+ */
+export interface KeyUsageCallbacks {
+ /** Current key states from the model registry (definition + active/exhausted status). */
+ listKeys(): KeyState[];
+ /** Discovered Claude accounts, used to resolve `anthropic` keys to credentials. */
+ listClaudeAccounts(): ClaudeAccount[];
+ /**
+ * Fetch an anthropic account's usage with provenance (live vs cache).
+ * Defaults to `getAccountUsageWithSource`.
+ */
+ fetchAnthropicUsage?: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
+ /**
+ * Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no
+ * local cache). Defaults to `fetchOpencodeUsage`.
+ */
+ fetchOpencodeUsage?: (keyId: string) => Promise<OpencodeUsageReport | null>;
+}
+
+/** A single normalized usage window (5-hour / week / month). */
+interface UsageWindow {
+ label: string;
+ /** Remaining headroom as a 0–100 percentage. Omitted when the source gives no utilization. */
+ remainingPercent?: number;
+ /** Epoch-ms the window resets. Omitted when the source gives no reset time. */
+ resetsAt?: number;
+}
+
+/** Fully normalized per-key usage, ready for rendering. */
+interface KeyUsageEntry {
+ keyId: string;
+ provider: string;
+ status: "active" | "exhausted";
+ lastError?: string;
+ exhaustedAt?: number;
+ /** Provenance of the usage figures: a fresh live fetch or a cached payload. */
+ dataSource?: "live" | "cache";
+ /** Epoch-ms the cached payload was last fetched from source (only on `dataSource: "cache"`). */
+ cachedAt?: number;
+ windows: UsageWindow[];
+ /** Set when no usage figures could be obtained for an otherwise-supported key. */
+ unavailableReason?: string;
+ /** Set when the provider has no usage-reporting support. */
+ unsupported?: boolean;
+}
+
+function clampPercent(value: number): number {
+ if (value < 0) return 0;
+ if (value > 100) return 100;
+ return value;
+}
+
+/** Convert a raw `{ utilization, resetsAt }` bucket into a normalized window. */
+function toWindow(
+ label: string,
+ bucket?: { utilization?: number; resetsAt?: number },
+): UsageWindow | null {
+ if (!bucket) return null;
+ const hasUtil = typeof bucket.utilization === "number";
+ const hasReset = typeof bucket.resetsAt === "number";
+ if (!hasUtil && !hasReset) return null;
+ return {
+ label,
+ ...(hasUtil
+ ? { remainingPercent: clampPercent(Math.round((1 - (bucket.utilization as number)) * 100)) }
+ : {}),
+ ...(hasReset ? { resetsAt: bucket.resetsAt } : {}),
+ };
+}
+
+function anthropicWindows(report: ClaudeUsageReport): UsageWindow[] {
+ const windows: UsageWindow[] = [];
+ const fiveHour = toWindow("5-hour", report.fiveHour);
+ if (fiveHour) windows.push(fiveHour);
+ const week = toWindow("week", report.sevenDay);
+ if (week) windows.push(week);
+ return windows;
+}
+
+function opencodeWindows(report: OpencodeUsageReport): UsageWindow[] {
+ const windows: UsageWindow[] = [];
+ const fiveHour = toWindow("5-hour", report.fiveHour);
+ if (fiveHour) windows.push(fiveHour);
+ const week = toWindow("week", report.weekly);
+ if (week) windows.push(week);
+ const month = toWindow("month", report.monthly);
+ if (month) windows.push(month);
+ return windows;
+}
+
+/**
+ * Resolve which Claude account backs an `anthropic` key. Matches by key id or by
+ * the account's source file (the key's `credentials_file`), falling back to the
+ * first available account — mirrors the existing `/models/key-usage` route.
+ */
+function matchAnthropicAccount(
+ accounts: ClaudeAccount[],
+ keyId: string,
+ credFile?: string,
+): ClaudeAccount | undefined {
+ const matched = accounts.find(
+ (a) => a.id === keyId || (credFile != null && a.source === credFile),
+ );
+ return matched ?? accounts[0];
+}
+
+function iso(ms: number): string {
+ return new Date(ms).toISOString();
+}
+
+/** Human-readable coarse duration, e.g. "3h 12m", "5d 8h", "0m". */
+function formatDuration(ms: number): string {
+ const totalSec = Math.round(Math.abs(ms) / 1000);
+ const days = Math.floor(totalSec / 86400);
+ const hours = Math.floor((totalSec % 86400) / 3600);
+ const minutes = Math.floor((totalSec % 3600) / 60);
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${days}d`);
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
+ return parts.join(" ");
+}
+
+function formatRelative(targetMs: number, nowMs: number): string {
+ const delta = targetMs - nowMs;
+ return delta >= 0 ? `in ${formatDuration(delta)}` : `${formatDuration(delta)} ago`;
+}
+
+function formatWindow(window: UsageWindow, now: number): string {
+ const parts: string[] = [];
+ if (typeof window.remainingPercent === "number") {
+ parts.push(`${window.remainingPercent}% remaining`);
+ }
+ if (typeof window.resetsAt === "number") {
+ parts.push(`resets ${iso(window.resetsAt)} (${formatRelative(window.resetsAt, now)})`);
+ }
+ return `${window.label}: ${parts.join(", ")}`;
+}
+
+/**
+ * Render normalized usage entries into an AI-friendly text block. Pure — `now`
+ * is injected so relative timestamps are deterministic under test.
+ */
+export function formatKeyUsage(entries: KeyUsageEntry[], now: number): string {
+ if (entries.length === 0) return "No API keys matched.";
+
+ const lines: string[] = [];
+ lines.push(`API key usage — ${entries.length} key${entries.length === 1 ? "" : "s"}:`);
+
+ for (const entry of entries) {
+ lines.push("");
+ lines.push(`[${entry.keyId}] provider: ${entry.provider}`);
+
+ if (entry.status === "exhausted") {
+ const since =
+ typeof entry.exhaustedAt === "number"
+ ? ` (since ${iso(entry.exhaustedAt)}, ${formatRelative(entry.exhaustedAt, now)})`
+ : "";
+ lines.push(`status: EXHAUSTED${since}`);
+ if (entry.lastError) lines.push(`last error: ${entry.lastError}`);
+ } else {
+ lines.push("status: active");
+ }
+
+ if (entry.unsupported) {
+ lines.push(
+ `usage: not supported for provider "${entry.provider}" (only anthropic and opencode-go report usage)`,
+ );
+ continue;
+ }
+
+ if (entry.dataSource === "live") {
+ lines.push("data: live (fetched just now)");
+ } else if (entry.dataSource === "cache") {
+ lines.push(
+ typeof entry.cachedAt === "number"
+ ? `data: cached — last fetched from source ${iso(entry.cachedAt)} (${formatRelative(entry.cachedAt, now)})`
+ : "data: cached (source timestamp unknown)",
+ );
+ }
+
+ for (const window of entry.windows) {
+ lines.push(formatWindow(window, now));
+ }
+
+ if (entry.unavailableReason) {
+ lines.push(`usage: unavailable — ${entry.unavailableReason}`);
+ }
+ }
+
+ return lines.join("\n");
+}
+
+async function buildEntry(
+ key: KeyState,
+ accounts: ClaudeAccount[],
+ fetchAnthropic: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>,
+ fetchOpencode: (keyId: string) => Promise<OpencodeUsageReport | null>,
+): Promise<KeyUsageEntry> {
+ const def = key.definition;
+ const entry: KeyUsageEntry = {
+ keyId: def.id,
+ provider: def.provider,
+ status: key.status,
+ windows: [],
+ ...(key.lastError ? { lastError: key.lastError } : {}),
+ ...(typeof key.exhaustedAt === "number" ? { exhaustedAt: key.exhaustedAt } : {}),
+ };
+
+ if (def.provider === "anthropic") {
+ const account = matchAnthropicAccount(accounts, def.id, def.credentials_file);
+ if (!account) {
+ entry.unavailableReason = "no Claude account credentials available for this key";
+ return entry;
+ }
+ let result: ClaudeUsageResult | null = null;
+ try {
+ result = await fetchAnthropic(account);
+ } catch {
+ result = null;
+ }
+ if (!result) {
+ entry.unavailableReason = "no live usage data and no cached usage available";
+ return entry;
+ }
+ entry.dataSource = result.source;
+ if (typeof result.cachedAt === "number") entry.cachedAt = result.cachedAt;
+ entry.windows = anthropicWindows(result.report);
+ if (entry.windows.length === 0) {
+ entry.unavailableReason = "usage endpoint returned no window data";
+ }
+ return entry;
+ }
+
+ if (def.provider === "opencode-go") {
+ let report: OpencodeUsageReport | null = null;
+ try {
+ report = await fetchOpencode(def.id);
+ } catch {
+ report = null;
+ }
+ if (!report) {
+ entry.unavailableReason =
+ "live usage unavailable (requires OPENCODE_COOKIE and a workspace id, or the source returned no data; OpenCode keeps no local cache)";
+ return entry;
+ }
+ entry.dataSource = "live";
+ entry.windows = opencodeWindows(report);
+ if (entry.windows.length === 0) {
+ entry.unavailableReason = "usage source returned no window data";
+ }
+ return entry;
+ }
+
+ entry.unsupported = true;
+ return entry;
+}
+
+export function createKeyUsageTool(callbacks: KeyUsageCallbacks): ToolDefinition {
+ const fetchAnthropic = callbacks.fetchAnthropicUsage ?? getAccountUsageWithSource;
+ const fetchOpencode = callbacks.fetchOpencodeUsage ?? defaultFetchOpencodeUsage;
+
+ return {
+ name: "key_usage",
+ description: [
+ "Report current usage levels for configured API keys so you can pick a key with",
+ "headroom, warn before hitting a rate limit, or diagnose an exhausted-key failure.",
+ "",
+ "For each key it returns: provider, active/exhausted status (with the last error when",
+ "exhausted), remaining rate-limit headroom per window (5-hour, weekly, and monthly where",
+ "the provider exposes it), each window's reset timestamp, and whether the figures are",
+ "live or served from cache (with the cache's last-fetched time).",
+ "",
+ "Pass a key_id to inspect one key; omit it to report all keys. Usage reporting is",
+ "supported for anthropic and opencode-go keys.",
+ ].join("\n"),
+ parameters: z.object({
+ key_id: z
+ .string()
+ .optional()
+ .describe(
+ 'The id of a single key to report (as configured in dispatch.toml, e.g. "claude-max"). Omit to report all configured keys.',
+ ),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const requestedKeyId = (args.key_id as string | undefined)?.trim() || undefined;
+
+ const allKeys = callbacks.listKeys();
+ if (allKeys.length === 0) {
+ return "No API keys are configured.";
+ }
+
+ let keys = allKeys;
+ if (requestedKeyId) {
+ keys = allKeys.filter((k) => k.definition.id === requestedKeyId);
+ if (keys.length === 0) {
+ const available = allKeys.map((k) => k.definition.id).join(", ");
+ return `Error: no key found with id "${requestedKeyId}". Available keys: ${available}.`;
+ }
+ }
+
+ const accounts = callbacks.listClaudeAccounts();
+ const entries: KeyUsageEntry[] = [];
+ for (const key of keys) {
+ entries.push(await buildEntry(key, accounts, fetchAnthropic, fetchOpencode));
+ }
+
+ return formatKeyUsage(entries, Date.now());
+ },
+ };
+}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index b941152..2a076e6 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -287,6 +287,7 @@ export function createSummonTool(
"write_file",
"run_shell",
"search_code",
+ "key_usage",
"todo",
"summon",
"retrieve",
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index f7944c9..4e3fa0b 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -76,8 +76,57 @@ export interface SystemChunk {
export interface ChatMessage {
role: MessageRole;
chunks: Chunk[];
+ /**
+ * Ephemeral ORDERED multimodal content for a user turn (interleaved text +
+ * image/pdf attachments). Set ONLY transiently on the in-flight user message
+ * so `toModelMessages` can emit multimodal `ImagePart`/`FilePart` content to
+ * the provider. Never persisted (the chunk log stores only the text, with
+ * `[image]`/`[pdf]` markers), so it's absent on history-rebuilt messages.
+ * When absent, the message is plain text built from its `chunks`.
+ */
+ content?: UserContentPart[];
}
+// ─── Multimodal user content (image / PDF attachments) ───────────
+//
+// When a user pastes one or more images/PDFs into the chat input, the turn's
+// user message carries an ORDERED list of content parts instead of a plain
+// string. The ordering is meaningful — the user can interleave text and
+// attachments ("here is image A: <A>, here is image B: <B>") and the model
+// sees them in exactly that sequence.
+//
+// These parts are EPHEMERAL: they are forwarded to the model for the turn that
+// produced them but are NOT persisted as raw bytes in the chunk log. History
+// stores only the user's text (with `[image]` / `[pdf]` markers in place of
+// each attachment), so a later reload re-renders the text but never re-sends
+// the binary payload. This keeps the persisted log small and avoids re-billing
+// image tokens on every subsequent turn.
+
+/** A plain-text segment of a multimodal user message. */
+export interface UserTextPart {
+ type: "text";
+ text: string;
+}
+
+/**
+ * A binary attachment (image or PDF) in a multimodal user message. `data` is a
+ * base64-encoded payload (no `data:` URI prefix); `mediaType` is the IANA media
+ * type (e.g. `image/png`, `application/pdf`). `name` is an optional original
+ * filename, used only for PDF `filename` passthrough and diagnostics.
+ */
+export interface UserAttachmentPart {
+ type: "attachment";
+ /** IANA media type, e.g. `image/png`, `image/jpeg`, `application/pdf`. */
+ mediaType: string;
+ /** Base64-encoded bytes WITHOUT a `data:` URI prefix. */
+ data: string;
+ /** Optional original filename (mainly for PDFs). */
+ name?: string;
+}
+
+/** One ordered part of a multimodal user message. */
+export type UserContentPart = UserTextPart | UserAttachmentPart;
+
// ─── Append-only chunk log (persisted model) ─────────────────────
//
// The DB stores a conversation as a flat stream of `ChunkRow`s (see