diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/core/src/models | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-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.ts | 151 | ||||
| -rw-r--r-- | packages/core/src/models/catalog.ts | 229 | ||||
| -rw-r--r-- | packages/core/src/models/index.ts | 24 | ||||
| -rw-r--r-- | packages/core/src/models/registry.ts | 86 |
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, - ); - } -} |
