summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/models
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/models
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/core/src/models')
-rw-r--r--packages/core/src/models/attachments.ts151
-rw-r--r--packages/core/src/models/catalog.ts229
-rw-r--r--packages/core/src/models/index.ts24
-rw-r--r--packages/core/src/models/registry.ts86
4 files changed, 0 insertions, 490 deletions
diff --git a/packages/core/src/models/attachments.ts b/packages/core/src/models/attachments.ts
deleted file mode 100644
index 5c98db4..0000000
--- a/packages/core/src/models/attachments.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-// 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
deleted file mode 100644
index ac310b1..0000000
--- a/packages/core/src/models/catalog.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
-import { dirname } from "node:path";
-
-/**
- * models.dev-backed model catalog. Resolves a model's MAXIMUM context window
- * (`limit.context`) dynamically from the public models.dev API, mirroring how
- * opencode determines per-model context limits — no hardcoded table.
- *
- * The catalog is fetched once, cached on disk with a short TTL, and reused. On
- * fetch failure we fall back to a stale-but-present cache so the lookup keeps
- * working offline. Lookups never throw: an unknown/unreachable model resolves
- * to `null`, which the UI renders as "max unknown".
- */
-
-/** Shape of the slice of models.dev's `/api.json` we consume. */
-interface ModelsDevModel {
- limit?: {
- 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 {
- id: string;
- models: Record<string, ModelsDevModel | undefined>;
-}
-
-type ModelsDevCatalog = Record<string, ModelsDevProvider | undefined>;
-
-/** Where models.dev's API lives. Overridable for tests / private mirrors. */
-const MODELS_URL = process.env.DISPATCH_MODELS_URL || "https://models.dev";
-
-/** Disk cache path (reuses the repo's `/tmp/dispatch` convention). */
-const CACHE_PATH = "/tmp/dispatch/models-dev.json";
-
-/** How long a cached catalog stays fresh before we re-fetch. */
-const CACHE_TTL_MS = 5 * 60 * 1000;
-
-/** Network timeout for the catalog fetch. */
-const FETCH_TIMEOUT_MS = 10_000;
-
-/**
- * After a failed fetch we memoize the fallback for this long before retrying,
- * so a sustained outage doesn't make every lookup hang on a fresh timeout.
- */
-const FETCH_PENALTY_MS = 60_000;
-
-/**
- * Dispatch provider id → models.dev provider ids to search, in priority order.
- * We only support Claude-backed providers (per product scope). `anthropic` and
- * `opencode-anthropic` are both Claude; we try the first-party `anthropic`
- * catalog first, then the `opencode` gateway catalog as a fallback.
- */
-const PROVIDER_MAP: Record<string, string[]> = {
- anthropic: ["anthropic", "opencode"],
- "opencode-anthropic": ["anthropic", "opencode"],
-};
-
-/** In-process memoized catalog promise (one fetch/parse per TTL window). */
-let cached: { catalog: ModelsDevCatalog; fetchedAt: number } | null = null;
-let inflight: Promise<ModelsDevCatalog> | null = null;
-
-function readDiskCache(): { catalog: ModelsDevCatalog; mtimeMs: number } | null {
- try {
- const stat = statSync(CACHE_PATH);
- const text = readFileSync(CACHE_PATH, "utf-8");
- return { catalog: JSON.parse(text) as ModelsDevCatalog, mtimeMs: stat.mtimeMs };
- } catch {
- return null;
- }
-}
-
-function writeDiskCache(text: string): void {
- try {
- mkdirSync(dirname(CACHE_PATH), { recursive: true });
- // Write-then-rename so a concurrent reader never sees a half-written
- // file (rename is atomic on the same filesystem). The temp name is
- // process-scoped to avoid two writers clobbering each other's temp.
- const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
- writeFileSync(tmp, text, "utf-8");
- renameSync(tmp, CACHE_PATH);
- } catch {
- // Best-effort: a read-only /tmp shouldn't break lookups.
- }
-}
-
-async function fetchCatalog(): Promise<ModelsDevCatalog> {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
- try {
- const res = await fetch(`${MODELS_URL}/api.json`, { signal: controller.signal });
- if (!res.ok) throw new Error(`models.dev returned HTTP ${res.status}`);
- const text = await res.text();
- const catalog = JSON.parse(text) as ModelsDevCatalog;
- writeDiskCache(text);
- return catalog;
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Load the models.dev catalog, preferring in-process memo, then a fresh disk
- * cache, then a network fetch. On network failure, falls back to any stale
- * disk cache; if nothing is available, returns an empty catalog.
- */
-export async function getModelsCatalog(): Promise<ModelsDevCatalog> {
- if (process.env.DISPATCH_DISABLE_MODELS_FETCH) {
- const disk = readDiskCache();
- return disk?.catalog ?? {};
- }
-
- const now = Date.now();
- if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.catalog;
-
- // Fresh disk cache satisfies the request without a network round-trip.
- const disk = readDiskCache();
- if (disk && now - disk.mtimeMs < CACHE_TTL_MS) {
- // Inherit the file's mtime as `fetchedAt` so loading a disk cache into
- // a fresh process doesn't reset its TTL (which would otherwise double
- // the worst-case staleness across process boundaries).
- cached = { catalog: disk.catalog, fetchedAt: disk.mtimeMs };
- return disk.catalog;
- }
-
- if (!inflight) {
- inflight = fetchCatalog()
- .then((catalog) => {
- cached = { catalog, fetchedAt: Date.now() };
- return catalog;
- })
- .catch((err) => {
- // Network failed — serve a stale cache if we have one.
- console.warn(
- `dispatch: failed to fetch models.dev catalog: ${err instanceof Error ? err.message : String(err)}`,
- );
- const fallback = disk?.catalog ?? ({} as ModelsDevCatalog);
- // Memoize the fallback with a short "penalty" TTL so a sustained
- // outage doesn't make every lookup hang on a fresh 10s timeout.
- // `fetchedAt` is backdated so the memo expires after FETCH_PENALTY_MS.
- cached = {
- catalog: fallback,
- fetchedAt: Date.now() - CACHE_TTL_MS + FETCH_PENALTY_MS,
- };
- return fallback;
- })
- .finally(() => {
- inflight = null;
- });
- }
- return inflight;
-}
-
-/**
- * Resolve a model's maximum context window (in tokens) for the given Dispatch
- * provider + model id. Returns `null` when the provider is unsupported, the
- * model is unknown, or the catalog is unavailable — callers should render that
- * as "max unknown" (no denominator / percentage).
- */
-export async function resolveContextLimit(
- provider: string,
- modelId: string,
-): Promise<number | null> {
- const candidates = PROVIDER_MAP[provider];
- if (!candidates || !modelId) return null;
-
- const catalog = await getModelsCatalog();
- for (const providerId of candidates) {
- const ctx = catalog[providerId]?.models?.[modelId]?.limit?.context;
- if (typeof ctx === "number" && ctx > 0) return ctx;
- }
- 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;
- inflight = null;
-}
diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts
deleted file mode 100644
index 15d1ee2..0000000
--- a/packages/core/src/models/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-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/models/registry.ts b/packages/core/src/models/registry.ts
deleted file mode 100644
index 4a24a51..0000000
--- a/packages/core/src/models/registry.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import type { KeyDefinition, KeyState } from "../types/index.js";
-
-export class ModelRegistry {
- private keyStates: Map<string, KeyState>;
- private keyOrder: string[];
-
- constructor(keys: KeyDefinition[]) {
- this.keyStates = new Map();
- this.keyOrder = [];
- this._initConfig(keys, new Map());
- }
-
- private _initConfig(keys: KeyDefinition[], existingStates: Map<string, KeyState>): void {
- this.keyOrder = keys.map((k) => k.id);
-
- const newStates = new Map<string, KeyState>();
- for (const key of keys) {
- const existing = existingStates.get(key.id);
- if (existing) {
- // Preserve existing state but update definition
- newStates.set(key.id, { ...existing, definition: key });
- } else {
- newStates.set(key.id, { definition: key, status: "active" });
- }
- }
- this.keyStates = newStates;
- }
-
- getKeys(): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter((state): state is KeyState => state !== undefined);
- }
-
- markKeyExhausted(keyId: string, error?: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- this.keyStates.set(keyId, {
- ...state,
- status: "exhausted",
- lastError: error,
- exhaustedAt: Date.now(),
- });
- }
-
- markKeyActive(keyId: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- const updated: KeyState = {
- definition: state.definition,
- status: "active",
- };
- this.keyStates.set(keyId, updated);
- }
-
- hasAvailableKey(provider: string): boolean {
- for (const state of this.keyStates.values()) {
- if (state.definition.provider === provider && state.status === "active") {
- return true;
- }
- }
- return false;
- }
-
- allKeysExhausted(): boolean {
- for (const state of this.keyStates.values()) {
- if (state.status === "active") {
- return false;
- }
- }
- return true;
- }
-
- updateConfig(keys: KeyDefinition[]): void {
- this._initConfig(keys, this.keyStates);
- }
-
- // Internal: get ordered key states for a specific provider
- getOrderedKeysForProvider(provider: string): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter(
- (state): state is KeyState => state !== undefined && state.definition.provider === provider,
- );
- }
-}