diff options
| author | Adam Malczewski <[email protected]> | 2026-06-03 08:24:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-03 08:24:40 +0900 |
| commit | bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a (patch) | |
| tree | 17e84ebf8d83c51a7a50312c256372a86e38b92a /packages/core/src | |
| parent | b26821ead97b986f886065b20d3dbde8283daa64 (diff) | |
| parent | ae672fd4f5542a2c217cf97657bf81eeebdaabbd (diff) | |
| download | dispatch-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.ts | bin | 57822 -> 60515 bytes | |||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 69 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 2 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 18 | ||||
| -rw-r--r-- | packages/core/src/models/attachments.ts | 151 | ||||
| -rw-r--r-- | packages/core/src/models/catalog.ts | 50 | ||||
| -rw-r--r-- | packages/core/src/models/index.ts | 19 | ||||
| -rw-r--r-- | packages/core/src/tools/key-usage.ts | 322 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 49 |
10 files changed, 670 insertions, 11 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts Binary files differindex 4bfa7eb..08b317a 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts 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 |
