From 24b57545c34ca6a349668fee54b3e7fe9d9f3231 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 11:01:07 -0500 Subject: [PATCH] feat(sync): migrate DigitalOcean model sync --- .github/workflows/sync-models.yml | 2 + package.json | 2 +- packages/core/script/generate-digitalocean.ts | 735 ------------------ packages/core/src/sync/index.ts | 5 +- .../core/src/sync/providers/digitalocean.ts | 446 +++++++++++ packages/core/test/sync.test.ts | 163 ++++ .../models/openai-gpt-5.4-mini.toml | 5 +- .../models/openai-gpt-5.4-nano.toml | 5 +- .../models/openai-gpt-5.4-pro.toml | 5 +- .../digitalocean/models/openai-gpt-5.4.toml | 5 +- .../digitalocean/models/openai-gpt-5.5.toml | 5 +- sync.md | 9 + 12 files changed, 640 insertions(+), 747 deletions(-) delete mode 100644 packages/core/script/generate-digitalocean.ts create mode 100644 packages/core/src/sync/providers/digitalocean.ts diff --git a/.github/workflows/sync-models.yml b/.github/workflows/sync-models.yml index 030feac747..5c980752dc 100644 --- a/.github/workflows/sync-models.yml +++ b/.github/workflows/sync-models.yml @@ -66,6 +66,8 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} BASETEN_API_KEY: ${{ secrets.BASETEN_API_KEY }} DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }} + DIGITALOCEAN_API_TOKEN: ${{ secrets.DIGITALOCEAN_API_TOKEN }} + DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} HF_TOKEN: ${{ secrets.HF_TOKEN }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }} diff --git a/package.json b/package.json index eea05d7906..fc5b512d66 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "venice:sync": "bun ./packages/core/script/sync-models.ts venice", "vercel:generate": "bun ./packages/core/script/sync-models.ts vercel", "wandb:generate": "bun ./packages/core/script/generate-wandb.ts", - "digitalocean:generate": "bun ./packages/core/script/generate-digitalocean.ts", + "digitalocean:sync": "bun ./packages/core/script/sync-models.ts digitalocean", "ambient:generate": "bun ./packages/core/script/generate-ambient.ts", "models:sync": "bun ./packages/core/script/sync-models.ts" }, diff --git a/packages/core/script/generate-digitalocean.ts b/packages/core/script/generate-digitalocean.ts deleted file mode 100644 index 88916bbc4f..0000000000 --- a/packages/core/script/generate-digitalocean.ts +++ /dev/null @@ -1,735 +0,0 @@ -#!/usr/bin/env bun - -/** - * Generates DigitalOcean model TOML files from two public APIs: - * - * - https://api.digitalocean.com/v2/gen-ai/models (model metadata, lifecycle, modalities, limits) - * - https://www.digitalocean.com/api/static-content/v1/products (pricing, including >200k tiers) - * - * The v2 models API requires a DigitalOcean personal access token or model access key, - * read from the DIGITALOCEAN_API_TOKEN environment variable (or --api-key flag). - * The static-content pricing API is public and requires no auth. - * - * Cache pricing (cache_read, cache_write) is NOT available from any DO API and is - * preserved from existing TOML files when present. - * - * Fields the APIs cannot provide (preserved from existing TOMLs, never overwritten): - * family, knowledge, open_weights, interleaved, attachment, release_date, - * cache_read, cache_write - * - * Flags: - * --dry-run Preview changes without writing files - * --new-only Only create new models, skip updating existing ones - * --api-key= DigitalOcean API key (overrides DIGITALOCEAN_API_TOKEN env var) - */ - -import { z } from "zod"; -import path from "node:path"; -import { mkdir } from "node:fs/promises"; -import { inferKimiFamily, ModelFamilyValues } from "../src/family.js"; - -const MODELS_API = "https://api.digitalocean.com/v2/gen-ai/models"; -const PRICING_API = "https://www.digitalocean.com/api/static-content/v1/products"; - -// --------------------------------------------------------------------------- -// v2 models API schema -// --------------------------------------------------------------------------- - -const DoModel = z - .object({ - id: z.string(), - name: z.string(), - lifecycle_status: z.string(), - type: z.string().optional(), - thinking: z.boolean().optional(), - context_window: z.union([z.number(), z.string()]).optional(), - modalities: z - .object({ - input: z.array(z.string()).optional(), - output: z.array(z.string()).optional(), - }) - .optional(), - settings: z - .array( - z.object({ - name: z.string(), - max: z.number().optional(), - default_value: z.number().optional(), - }), - ) - .optional(), - created_at: z.string().optional(), - }) - .passthrough(); - -const DoModelsResponse = z - .object({ - models: z.array(DoModel), - }) - .passthrough(); - -// --------------------------------------------------------------------------- -// static-content pricing API schema -// --------------------------------------------------------------------------- - -const PricingEntry = z - .object({ - name: z.string(), - slug: z.string(), - model: z.string(), - prompt_tokens: z.string().optional(), // "≤200k" | ">200k" | undefined - price: z.object({ rate: z.number() }), - }) - .passthrough(); - -const StaticContentResponse = z - .object({ - gradient: z.object({ - models: z.array(PricingEntry), - }), - }) - .passthrough(); - -// --------------------------------------------------------------------------- -// Derived pricing map -// --------------------------------------------------------------------------- - -interface ModelPricing { - input: number; - output: number; - inputOver200k?: number; - outputOver200k?: number; -} - -// Map marketing names from /v1/products to API model IDs from /v2/gen-ai/models. -// The pricing API uses display names, not the machine IDs, so this table is the -// join key. Add entries here when DO adds new models with tiered pricing. -const PRICING_NAME_MAP: Record = { - // Anthropic - "claude sonnet 4.6": "anthropic-claude-4.6-sonnet", - "claude sonnet 4.5": "anthropic-claude-4.5-sonnet", - "claude sonnet 4": "anthropic-claude-sonnet-4", - "claude haiku 4.5": "anthropic-claude-haiku-4.5", - "claude opus 4.6": "anthropic-claude-opus-4.6", - "claude opus 4.5": "anthropic-claude-opus-4.5", - "claude opus 4.1": "anthropic-claude-4.1-opus", - "claude opus 4": "anthropic-claude-opus-4", - // OpenAI - "gpt-5.4": "openai-gpt-5.4", - "gpt-5.4 mini": "openai-gpt-5.4-mini", - "gpt-5.4 nano": "openai-gpt-5.4-nano", - "gpt-5.4 pro": "openai-gpt-5.4-pro", - "gpt-5.3-codex": "openai-gpt-5.3-codex", - "gpt-5.2": "openai-gpt-5.2", - "gpt-5.2 pro": "openai-gpt-5.2-pro", - "gpt-5.1-codex-max": "openai-gpt-5.1-codex-max", - "gpt-5": "openai-gpt-5", - "gpt-5 mini": "openai-gpt-5-mini", - "gpt-5 nano": "openai-gpt-5-nano", - "gpt-4.1": "openai-gpt-4.1", - "gpt image 1": "openai-gpt-image-1", - "gpt image 1.5": "openai-gpt-image-1.5", - "gpt-oss-120b": "openai-gpt-oss-120b", - "gpt-oss-20b": "openai-gpt-oss-20b", - "gpt-4o": "openai-gpt-4o", - "gpt-4o mini": "openai-gpt-4o-mini", - "o1": "openai-o1", - "o3-mini": "openai-o3-mini", - // DeepSeek - "deepseek r1 distill llama 70b": "deepseek-r1-distill-llama-70b", - // Llama - "llama 3.3 70b": "llama3.3-70b-instruct", - // DO-hosted - "qwen3-32b": "alibaba-qwen3-32b", - "minimax m2.5 (public preview)": "minimax-m2.5", - "kimi k2.5": "kimi-k2", - "nvidia nemotron 3 super 120b (public preview)": "nvidia-nemotron-3-super-120b", - "glm 5": "glm-5", -}; - -function normalizeDisplayName(raw: string): string { - // Strip " Input Tokens" / " Output Tokens" suffix and lowercase - return raw - .replace(/\s+(input|output)\s+tokens$/i, "") - .trim() - .toLowerCase(); -} - -function buildPricingMap(entries: z.infer[]): Map { - const map = new Map(); - - for (const entry of entries) { - const displayName = normalizeDisplayName(entry.name); - const modelId = PRICING_NAME_MAP[displayName]; - if (!modelId) continue; - - const isInput = entry.name.toLowerCase().includes("input tokens"); - const isOver200k = entry.prompt_tokens === ">200k"; - // Round to avoid float noise (e.g. 0.9900000000000001) - const rate = Math.round(entry.price.rate * 10000) / 10000; - - const existing = map.get(modelId) ?? ({} as ModelPricing); - - if (isInput && isOver200k) existing.inputOver200k = rate; - else if (!isInput && isOver200k) existing.outputOver200k = rate; - else if (isInput) existing.input = rate; - else existing.output = rate; - - map.set(modelId, existing); - } - - return map; -} - -// --------------------------------------------------------------------------- -// Existing TOML shape (fields we read and may preserve) -// --------------------------------------------------------------------------- - -interface ExistingModel { - name?: string; - family?: string; - attachment?: boolean; - reasoning?: boolean; - tool_call?: boolean; - structured_output?: boolean; - temperature?: boolean; - knowledge?: string; - release_date?: string; - last_updated?: string; - open_weights?: boolean; - interleaved?: boolean | { field: string }; - status?: string; - cost?: { - input?: number; - output?: number; - cache_read?: number; - cache_write?: number; - context_over_200k?: { - input?: number; - output?: number; - cache_read?: number; - cache_write?: number; - context_min?: number; - }; - tiers?: Array<{ - tier: { - type?: "context"; - size: number; - }; - input?: number; - output?: number; - cache_read?: number; - cache_write?: number; - }>; - }; - limit?: { - context?: number; - input?: number; - output?: number; - }; - modalities?: { - input?: string[]; - output?: string[]; - }; -} - -async function loadExisting(filePath: string): Promise { - const file = Bun.file(filePath); - if (!(await file.exists())) return null; - try { - const mod = await import(filePath, { with: { type: "toml" } }); - return mod.default as ExistingModel; - } catch (e) { - console.warn(`Warning: failed to parse ${filePath}:`, e); - return null; - } -} - -// --------------------------------------------------------------------------- -// Merged model shape (what we write) -// --------------------------------------------------------------------------- - -interface MergedModel { - name: string; - family?: string; - attachment: boolean; - reasoning: boolean; - tool_call: boolean; - structured_output?: boolean; - temperature: boolean; - knowledge?: string; - release_date: string; - last_updated: string; - open_weights: boolean; - interleaved?: boolean | { field: string }; - status?: string; - cost?: { - input: number; - output: number; - cache_read?: number; - cache_write?: number; - context_over_200k?: { - input: number; - output: number; - cache_read?: number; - cache_write?: number; - context_min?: number; - }; - }; - limit: { - context: number; - output: number; - }; - modalities: { - input: string[]; - output: string[]; - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const VALID_INPUT_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]); -const VALID_OUTPUT_MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]); - -function filterInputModalities(raw: string[]): string[] { - return raw.filter((m) => VALID_INPUT_MODALITIES.has(m)); -} - -function filterOutputModalities(raw: string[]): string[] { - // "code" is not a valid modality in the schema — map to "text" - return [...new Set(raw.map((m) => (m === "code" ? "text" : m)).filter((m) => VALID_OUTPUT_MODALITIES.has(m)))]; -} - -function getTodayDate(): string { - return new Date().toISOString().slice(0, 10); -} - -function formatNumber(n: number): string { - return n >= 1000 ? n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_") : n.toString(); -} - -function inferFamily(modelId: string, modelName: string): string | undefined { - const kimiFamily = inferKimiFamily(modelId, modelName); - if (kimiFamily !== undefined) return kimiFamily; - - const sorted = [...ModelFamilyValues].sort((a, b) => b.length - a.length); - const targets = [modelId.toLowerCase(), modelName.toLowerCase()]; - for (const family of sorted) { - const f = family.toLowerCase(); - for (const t of targets) { - if (t.includes(f)) return family; - } - } - return undefined; -} - -function getExistingLongContextCost(existing: ExistingModel | null) { - const tier = existing?.cost?.tiers?.find( - (tier) => - (tier.tier.type === undefined || tier.tier.type === "context") && - tier.tier.size >= 200_000, - ); - if (tier) { - return { - ...tier, - context_min: tier.tier.size, - }; - } - - return existing?.cost?.context_over_200k === undefined - ? undefined - : { - ...existing.cost.context_over_200k, - context_min: 200_000, - }; -} - -function getLongContextMin(cost: { context_min?: number }) { - return cost.context_min ?? 200_000; -} - -function formatInlineNumber(n: number): string { - return n >= 1000 ? n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "_") : n.toString(); -} - -// --------------------------------------------------------------------------- -// Merge API data with existing TOML -// --------------------------------------------------------------------------- - -function mergeModel( - apiModel: z.infer, - pricing: ModelPricing | undefined, - existing: ExistingModel | null, -): MergedModel { - const rawInput = apiModel.modalities?.input ?? []; - const rawOutput = apiModel.modalities?.output ?? []; - const inputMods = filterInputModalities(rawInput.length > 0 ? rawInput : existing?.modalities?.input ?? ["text"]); - const outputMods = filterOutputModalities(rawOutput.length > 0 ? rawOutput : existing?.modalities?.output ?? ["text"]); - - const maxTokensSetting = apiModel.settings?.find((s) => s.name === "max_tokens"); - const maxTokens = maxTokensSetting?.max ?? existing?.limit?.output ?? 0; - - const rawContext = apiModel.context_window; - const contextWindow = - rawContext !== undefined - ? typeof rawContext === "string" - ? parseInt(rawContext, 10) - : rawContext - : (existing?.limit?.context ?? 0); - - const isDeprecated = apiModel.lifecycle_status === "end_of_life"; - - // Fields preserved from existing TOML (APIs don't provide these) - const family = existing?.family ?? inferFamily(apiModel.id, apiModel.name); - const knowledge = existing?.knowledge; - const openWeights = existing?.open_weights ?? false; - const interleaved = existing?.interleaved; - const attachment = existing?.attachment ?? inputMods.some((m) => m !== "text"); - - // reasoning: trust existing if set, else use API thinking flag as a hint - // (thinking flag is unreliable for non-LLM models so gate on output modality) - const isTextOutput = outputMods.includes("text") && !outputMods.includes("image") && !outputMods.includes("video"); - const reasoning = existing?.reasoning ?? (isTextOutput && (apiModel.thinking ?? false)); - - // tool_call: no API signal, preserve existing or default true for text models - const toolCall = existing?.tool_call ?? isTextOutput; - - // temperature: no API signal, preserve or default true - const temperature = existing?.temperature ?? true; - - // structured_output: no API signal, preserve only - const structuredOutput = existing?.structured_output; - - const releaseDate = existing?.release_date ?? apiModel.created_at?.slice(0, 10) ?? getTodayDate(); - - const merged: MergedModel = { - name: apiModel.name, - family, - attachment, - reasoning, - tool_call: toolCall, - temperature, - release_date: releaseDate, - last_updated: getTodayDate(), - open_weights: openWeights, - ...(structuredOutput !== undefined && { structured_output: structuredOutput }), - ...(knowledge && { knowledge }), - ...(interleaved !== undefined && { interleaved }), - ...(isDeprecated && { status: "deprecated" }), - limit: { context: contextWindow, output: maxTokens }, - modalities: { input: inputMods, output: outputMods }, - }; - - // Pricing: static-content API is the sole source of truth for prices. - // The v2 models API pricing is intentionally ignored. If a model has no - // entry in the static-content API, preserve existing TOML prices. - const inputPrice = pricing?.input ?? existing?.cost?.input; - const outputPrice = pricing?.output ?? existing?.cost?.output; - - if (inputPrice !== undefined && outputPrice !== undefined) { - merged.cost = { - input: inputPrice, - output: outputPrice, - // Always preserve cache pricing — not available from any DO API - ...(existing?.cost?.cache_read !== undefined && { cache_read: existing.cost.cache_read }), - ...(existing?.cost?.cache_write !== undefined && { cache_write: existing.cost.cache_write }), - }; - - // Context-tiered pricing (>200k) from the static-content API - const existingLongContextCost = getExistingLongContextCost(existing); - if (pricing?.inputOver200k !== undefined && pricing?.outputOver200k !== undefined) { - merged.cost.context_over_200k = { - input: pricing.inputOver200k, - output: pricing.outputOver200k, - context_min: existingLongContextCost?.context_min ?? 200_000, - ...(existingLongContextCost?.cache_read !== undefined && { - cache_read: existingLongContextCost.cache_read, - }), - ...(existingLongContextCost?.cache_write !== undefined && { - cache_write: existingLongContextCost.cache_write, - }), - }; - } else if (existingLongContextCost) { - // Preserve manually-entered tiered pricing if API has no data - merged.cost.context_over_200k = { - input: existingLongContextCost.input ?? inputPrice, - output: existingLongContextCost.output ?? outputPrice, - context_min: existingLongContextCost.context_min, - ...(existingLongContextCost.cache_read !== undefined && { - cache_read: existingLongContextCost.cache_read, - }), - ...(existingLongContextCost.cache_write !== undefined && { - cache_write: existingLongContextCost.cache_write, - }), - }; - } - } - - return merged; -} - -// --------------------------------------------------------------------------- -// TOML serialiser -// --------------------------------------------------------------------------- - -function formatToml(model: MergedModel): string { - const lines: string[] = []; - - lines.push(`name = "${model.name.replace(/"/g, '\\"')}"`); - if (model.family) lines.push(`family = "${model.family}"`); - lines.push(`release_date = "${model.release_date}"`); - lines.push(`last_updated = "${model.last_updated}"`); - lines.push(`attachment = ${model.attachment}`); - lines.push(`reasoning = ${model.reasoning}`); - lines.push(`temperature = ${model.temperature}`); - lines.push(`tool_call = ${model.tool_call}`); - if (model.structured_output !== undefined) lines.push(`structured_output = ${model.structured_output}`); - if (model.knowledge) lines.push(`knowledge = "${model.knowledge}"`); - lines.push(`open_weights = ${model.open_weights}`); - if (model.status) lines.push(`status = "${model.status}"`); - - if (model.interleaved !== undefined) { - lines.push(""); - if (model.interleaved === true) { - lines.push(`interleaved = true`); - } else if (typeof model.interleaved === "object") { - lines.push(`[interleaved]`); - lines.push(`field = "${model.interleaved.field}"`); - } - } - - if (model.cost) { - lines.push(""); - lines.push(`[cost]`); - lines.push(`input = ${model.cost.input}`); - lines.push(`output = ${model.cost.output}`); - if (model.cost.cache_read !== undefined) lines.push(`cache_read = ${model.cost.cache_read}`); - if (model.cost.cache_write !== undefined) lines.push(`cache_write = ${model.cost.cache_write}`); - - if (model.cost.context_over_200k) { - lines.push(""); - lines.push(`[[cost.tiers]]`); - lines.push(`tier = { size = ${formatInlineNumber(getLongContextMin(model.cost.context_over_200k))} }`); - lines.push(`input = ${model.cost.context_over_200k.input}`); - lines.push(`output = ${model.cost.context_over_200k.output}`); - if (model.cost.context_over_200k.cache_read !== undefined) - lines.push(`cache_read = ${model.cost.context_over_200k.cache_read}`); - if (model.cost.context_over_200k.cache_write !== undefined) - lines.push(`cache_write = ${model.cost.context_over_200k.cache_write}`); - } - } - - lines.push(""); - lines.push(`[limit]`); - lines.push(`context = ${formatNumber(model.limit.context)}`); - lines.push(`output = ${formatNumber(model.limit.output)}`); - - lines.push(""); - lines.push(`[modalities]`); - lines.push(`input = [${model.modalities.input.map((m) => `"${m}"`).join(", ")}]`); - lines.push(`output = [${model.modalities.output.map((m) => `"${m}"`).join(", ")}]`); - - return lines.join("\n") + "\n"; -} - -// --------------------------------------------------------------------------- -// Change detection -// --------------------------------------------------------------------------- - -interface Change { - field: string; - oldValue: string; - newValue: string; -} - -function formatValue(val: unknown): string { - if (val === undefined) return "(none)"; - if (Array.isArray(val)) return `[${val.join(", ")}]`; - if (typeof val === "number") return formatNumber(val); - return String(val); -} - -function detectChanges(existing: ExistingModel | null, merged: MergedModel): Change[] { - if (!existing) return []; - - const changes: Change[] = []; - const EPSILON = 0.001; - - const compare = (field: string, oldVal: unknown, newVal: unknown) => { - if (oldVal === undefined && newVal === undefined) return; - const isDiff = field.startsWith("cost.") - ? Math.abs((oldVal as number ?? 0) - (newVal as number ?? 0)) > EPSILON - : JSON.stringify(oldVal) !== JSON.stringify(newVal); - if (isDiff) changes.push({ field, oldValue: formatValue(oldVal), newValue: formatValue(newVal) }); - }; - - compare("name", existing.name, merged.name); - compare("reasoning", existing.reasoning, merged.reasoning); - compare("tool_call", existing.tool_call, merged.tool_call); - compare("attachment", existing.attachment, merged.attachment); - compare("status", existing.status, merged.status); - compare("cost.input", existing.cost?.input, merged.cost?.input); - compare("cost.output", existing.cost?.output, merged.cost?.output); - const existingLongContextCost = getExistingLongContextCost(existing); - compare("cost.context_over_200k.input", existingLongContextCost?.input, merged.cost?.context_over_200k?.input); - compare("cost.context_over_200k.output", existingLongContextCost?.output, merged.cost?.context_over_200k?.output); - compare("limit.context", existing.limit?.context, merged.limit.context); - compare("limit.output", existing.limit?.output, merged.limit.output); - compare("modalities.input", existing.modalities?.input, merged.modalities.input); - compare("modalities.output", existing.modalities?.output, merged.modalities.output); - - return changes; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main() { - const args = process.argv.slice(2); - const dryRun = args.includes("--dry-run"); - const newOnly = args.includes("--new-only"); - - // Resolve API key - const apiKeyArg = args.find((a) => a.startsWith("--api-key")); - const apiKey = - (apiKeyArg?.includes("=") ? apiKeyArg.split("=")[1] : args[args.indexOf(apiKeyArg!) + 1]) ?? - process.env.DIGITALOCEAN_API_TOKEN; - - if (!apiKey) { - console.error("Error: DIGITALOCEAN_API_TOKEN is required (or pass --api-key=)"); - console.error("Get one from: https://cloud.digitalocean.com/account/api/tokens"); - process.exit(1); - } - - const modelsDir = path.join(import.meta.dirname, "..", "..", "..", "providers", "digitalocean", "models"); - - const prefix = dryRun ? "[DRY RUN] " : ""; - console.log(`${prefix}Fetching DigitalOcean models from API...`); - - // Fetch both APIs in parallel - const [modelsRes, pricingRes] = await Promise.all([ - fetch(MODELS_API, { headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" } }), - fetch(PRICING_API, { headers: { "User-Agent": "models.dev/digitalocean-sync" } }), - ]); - - if (!modelsRes.ok) { - console.error(`Failed to fetch models API: ${modelsRes.status} ${modelsRes.statusText}`); - if (modelsRes.status === 401 || modelsRes.status === 403) - console.error("Check your DIGITALOCEAN_API_TOKEN has read access."); - process.exit(1); - } - - if (!pricingRes.ok) { - console.error(`Failed to fetch pricing API: ${pricingRes.status} ${pricingRes.statusText}`); - process.exit(1); - } - - const modelsParsed = DoModelsResponse.safeParse(await modelsRes.json()); - if (!modelsParsed.success) { - console.error("Unexpected models API response:", modelsParsed.error.errors); - process.exit(1); - } - - const pricingParsed = StaticContentResponse.safeParse(await pricingRes.json()); - if (!pricingParsed.success) { - console.error("Unexpected pricing API response:", pricingParsed.error.errors); - process.exit(1); - } - - const apiModels = modelsParsed.data.models; - const pricingMap = buildPricingMap(pricingParsed.data.gradient.models); - - // Collect existing TOML filenames for orphan detection - const existingFiles = new Set(); - for await (const file of new Bun.Glob("**/*.toml").scan({ cwd: modelsDir, absolute: false })) { - existingFiles.add(file); - } - - console.log(`Found ${apiModels.length} models in API, ${existingFiles.size} existing TOML files\n`); - - const apiModelFiles = new Set(); - let created = 0; - let updated = 0; - let unchanged = 0; - - for (const apiModel of apiModels) { - // Skip non-text models that opencode can't use: image, video, audio, embedding, reranking - const outputMods = filterOutputModalities(apiModel.modalities?.output ?? []); - const isTextModel = outputMods.includes("text"); - const isEmbedding = apiModel.type === "embedding"; - const isReranking = apiModel.type === "reranking"; - if (!isTextModel || isEmbedding || isReranking) continue; - - // Model IDs may contain slashes (e.g. fal-ai/flux/schnell) — use as subpath - const relativePath = `${apiModel.id}.toml`; - const filePath = path.join(modelsDir, relativePath); - const dirPath = path.dirname(filePath); - - apiModelFiles.add(relativePath); - - const existing = await loadExisting(filePath); - const pricing = pricingMap.get(apiModel.id); - const merged = mergeModel(apiModel, pricing, existing); - const toml = formatToml(merged); - - if (existing === null) { - created++; - if (dryRun) { - console.log(`[DRY RUN] Would create: ${relativePath}`); - console.log(` name = "${merged.name}"`); - if (pricing) console.log(` pricing: $${merged.cost?.input}/$${merged.cost?.output} per M tokens`); - if (merged.family) console.log(` family = "${merged.family}" (inferred)`); - console.log(""); - } else { - await mkdir(dirPath, { recursive: true }); - await Bun.write(filePath, toml); - console.log(`Created: ${relativePath}`); - } - continue; - } - - if (newOnly) { - unchanged++; - continue; - } - - const changes = detectChanges(existing, merged); - if (changes.length > 0) { - updated++; - if (dryRun) { - console.log(`[DRY RUN] Would update: ${relativePath}`); - } else { - await mkdir(dirPath, { recursive: true }); - await Bun.write(filePath, toml); - console.log(`Updated: ${relativePath}`); - } - for (const c of changes) console.log(` ${c.field}: ${c.oldValue} → ${c.newValue}`); - console.log(""); - } else { - unchanged++; - } - } - - // Orphan detection: files in the TOML directory but not in the API - const orphaned: string[] = []; - for (const file of existingFiles) { - if (!apiModelFiles.has(file)) { - orphaned.push(file); - console.log(`Warning: orphaned file (not in API): ${file}`); - } - } - - console.log(""); - if (dryRun) { - console.log( - `Summary: ${created} would be created, ${updated} would be updated, ${unchanged} unchanged, ${orphaned.length} orphaned`, - ); - } else { - console.log(`Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${orphaned.length} orphaned`); - } -} - -await main(); diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 8c87514322..46512a81e1 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -9,6 +9,7 @@ import { baseten } from "./providers/baseten.js"; import { chutes } from "./providers/chutes.js"; import { cloudflareWorkersAi } from "./providers/cloudflare-workers-ai.js"; import { deepinfra } from "./providers/deepinfra.js"; +import { digitalocean } from "./providers/digitalocean.js"; import { google } from "./providers/google.js"; import { huggingface } from "./providers/huggingface.js"; import { llmgateway } from "./providers/llmgateway.js"; @@ -90,6 +91,7 @@ export const providers: { chutes: SyncProvider; "cloudflare-workers-ai": SyncProvider; deepinfra: SyncProvider; + digitalocean: SyncProvider; google: SyncProvider; huggingface: SyncProvider; llmgateway: SyncProvider; @@ -104,6 +106,7 @@ export const providers: { chutes, "cloudflare-workers-ai": cloudflareWorkersAi, deepinfra, + digitalocean, google, huggingface, llmgateway, @@ -117,7 +120,7 @@ export const providers: { export const groups = { aggregators: ["huggingface", "llmgateway", "openrouter", "vercel"], cloudflare: ["cloudflare-workers-ai"], - direct: ["anthropic", "baseten", "chutes", "deepinfra", "google", "ovhcloud", "venice", "xai"], + direct: ["anthropic", "baseten", "chutes", "deepinfra", "digitalocean", "google", "ovhcloud", "venice", "xai"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/digitalocean.ts b/packages/core/src/sync/providers/digitalocean.ts new file mode 100644 index 0000000000..b6cc9a372f --- /dev/null +++ b/packages/core/src/sync/providers/digitalocean.ts @@ -0,0 +1,446 @@ +import { z } from "zod"; + +import { describeModel } from "../../describe.js"; +import { inferKimiFamily, ModelFamilyValues } from "../../family.js"; +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js"; + +const MODELS_API = "https://api.digitalocean.com/v2/gen-ai/models?per_page=200"; +const PRICING_API = "https://www.digitalocean.com/api/static-content/v1/products"; + +export const DigitalOceanModel = z.object({ + id: z.string().min(1), + name: z.string().min(1), + lifecycle_status: z.string(), + type: z.string().optional(), + thinking: z.boolean().optional(), + context_window: z.union([z.number(), z.string()]).optional(), + modalities: z.object({ + input: z.array(z.string()).optional(), + output: z.array(z.string()).optional(), + }).optional(), + settings: z.array(z.object({ + name: z.string(), + max: z.number().optional(), + default_value: z.number().optional(), + })).optional(), + created_at: z.string().optional(), +}).passthrough(); + +const DigitalOceanModelsResponse = z.object({ + models: z.array(DigitalOceanModel), + links: z.object({ + pages: z.object({ + next: z.string().nullable().optional(), + }).passthrough().optional(), + }).passthrough().optional(), +}).passthrough(); + +const PricingEntry = z.object({ + name: z.string(), + slug: z.string(), + model: z.string(), + prompt_tokens: z.string().optional(), + price: z.object({ rate: z.number() }), +}).passthrough(); + +const DigitalOceanPricingResponse = z.object({ + gradient: z.object({ models: z.array(PricingEntry) }), +}).passthrough(); + +const DigitalOceanResponse = z.object({ + models: z.array(DigitalOceanModel), + pricing: z.array(PricingEntry), +}); + +export type DigitalOceanModel = z.infer; +type PricingEntry = z.infer; + +interface ModelPricing { + input?: number; + output?: number; + inputOver200k?: number; + outputOver200k?: number; +} + +export interface DigitalOceanSourceModel extends DigitalOceanModel { + pricing?: ModelPricing; +} + +const PRICING_NAME_OVERRIDES: Record = { + "claude sonnet 4.6": "anthropic-claude-4.6-sonnet", + "claude sonnet 4.5": "anthropic-claude-4.5-sonnet", + "claude sonnet 4": "anthropic-claude-sonnet-4", + "claude haiku 4.5": "anthropic-claude-haiku-4.5", + "claude opus 4.6": "anthropic-claude-opus-4.6", + "claude opus 4.5": "anthropic-claude-opus-4.5", + "claude opus 4.1": "anthropic-claude-4.1-opus", + "claude opus 4": "anthropic-claude-opus-4", + "gpt-5.4": "openai-gpt-5.4", + "gpt-5.4 mini": "openai-gpt-5.4-mini", + "gpt-5.4 nano": "openai-gpt-5.4-nano", + "gpt-5.4 pro": "openai-gpt-5.4-pro", + "gpt-5.3-codex": "openai-gpt-5.3-codex", + "gpt-5.2": "openai-gpt-5.2", + "gpt-5.2 pro": "openai-gpt-5.2-pro", + "gpt-5.1-codex-max": "openai-gpt-5.1-codex-max", + "gpt-5": "openai-gpt-5", + "gpt-5 mini": "openai-gpt-5-mini", + "gpt-5 nano": "openai-gpt-5-nano", + "gpt-4.1": "openai-gpt-4.1", + "gpt image 1": "openai-gpt-image-1", + "gpt image 1.5": "openai-gpt-image-1.5", + "gpt-oss-120b": "openai-gpt-oss-120b", + "gpt-oss-20b": "openai-gpt-oss-20b", + "gpt-4o": "openai-gpt-4o", + "gpt-4o mini": "openai-gpt-4o-mini", + o1: "openai-o1", + "o3-mini": "openai-o3-mini", + "deepseek r1 distill llama 70b": "deepseek-r1-distill-llama-70b", + "llama 3.3 70b": "llama3.3-70b-instruct", + "qwen3-32b": "alibaba-qwen3-32b", + "minimax m2.5": "minimax-m2.5", + "kimi k2.5": "kimi-k2.5", + "nvidia nemotron 3 super 120b": "nvidia-nemotron-3-super-120b", + "glm 5": "glm-5", +}; + +export const digitalocean = { + id: "digitalocean", + name: "DigitalOcean", + modelsDir: "providers/digitalocean/models", + deleteMissing: false, + sourceID(model) { + return model.id; + }, + skippedNotice(ids) { + if (ids.length === 0) return []; + return [ + `${ids.length} DigitalOcean text models could not be translated because required metadata was unavailable.`, + `Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`, + ]; + }, + missingNotice(paths) { + if (paths.length === 0) return []; + return [ + `${paths.length} local DigitalOcean models were outside the managed text-model catalog and were retained for manual lifecycle review.`, + `Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`, + ]; + }, + async fetchModels() { + const key = process.env.DIGITALOCEAN_API_TOKEN || process.env.DIGITALOCEAN_ACCESS_TOKEN; + if (!key) { + throw new Error("DigitalOcean sync requires DIGITALOCEAN_API_TOKEN or DIGITALOCEAN_ACCESS_TOKEN"); + } + return fetchDigitalOceanModels(key); + }, + parseModels(raw) { + return parseDigitalOceanModels(raw); + }, + translateModel(model, context) { + const existing = context.existing(model.id); + const contextWindow = number(model.context_window); + const outputLimit = model.settings?.find((setting) => setting.name === "max_tokens")?.max; + if ( + existing === undefined + && ( + model.pricing?.input === undefined + || model.pricing.output === undefined + || contextWindow === undefined + || contextWindow <= 0 + || outputLimit === undefined + || outputLimit <= 0 + ) + ) return undefined; + const baseModel = existing === undefined + ? resolveDigitalOceanBaseModel(model.id) + : existing.base_model; + return { + id: model.id, + model: buildDigitalOceanModel(model, existing, baseModel), + }; + }, +} satisfies SyncProvider; + +export async function fetchDigitalOceanModels(key: string, fetcher: typeof fetch = fetch) { + const [models, pricingResponse] = await Promise.all([ + fetchAllDigitalOceanModels(key, fetcher), + fetcher(PRICING_API, { + headers: { "User-Agent": "models.dev/digitalocean-sync" }, + }), + ]); + + if (!pricingResponse.ok) { + throw new Error(`DigitalOcean pricing request failed: ${pricingResponse.status} ${pricingResponse.statusText}`); + } + + const pricing = DigitalOceanPricingResponse.parse(await pricingResponse.json()).gradient.models; + return { models, pricing }; +} + +async function fetchAllDigitalOceanModels(key: string, fetcher: typeof fetch) { + const models: DigitalOceanModel[] = []; + const visited = new Set(); + let url: string | undefined = MODELS_API; + + while (url !== undefined) { + if (visited.has(url)) throw new Error(`DigitalOcean models pagination repeated URL: ${url}`); + visited.add(url); + + const response = await fetcher(url, { + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + }); + if (!response.ok) { + throw new Error(`DigitalOcean models request failed: ${response.status} ${response.statusText}`); + } + + const page = DigitalOceanModelsResponse.parse(await response.json()); + models.push(...page.models); + const next = page.links?.pages?.next; + url = next ? new URL(next, url).toString() : undefined; + } + return models; +} + +export function parseDigitalOceanModels(raw: unknown): DigitalOceanSourceModel[] { + const response = DigitalOceanResponse.parse(raw); + const pricing = buildPricingMap(response.pricing, response.models); + return response.models + .filter(isManagedTextModel) + .map((model) => ({ ...model, pricing: pricing.get(model.id) })); +} + +function isManagedTextModel(model: DigitalOceanModel) { + const output = normalizeModalities(model.modalities?.output ?? [], []); + return output.includes("text") && model.type !== "embedding" && model.type !== "reranking"; +} + +function pricingName(value: string) { + return value + .replace(/\s+(input|output)\s+tokens$/i, "") + .replace(/\s*\(public preview\)\s*/i, " ") + .trim() + .toLowerCase(); +} + +function normalizedName(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +export function buildPricingMap(entries: PricingEntry[], models: DigitalOceanModel[]) { + const names = new Map(); + for (const model of models) { + const key = normalizedName(model.name); + names.set(key, [...names.get(key) ?? [], model.id]); + } + + const result = new Map(); + for (const entry of entries) { + const name = pricingName(entry.name); + const matches = names.get(normalizedName(name)) ?? []; + const id = PRICING_NAME_OVERRIDES[name] ?? (matches.length === 1 ? matches[0] : undefined); + if (id === undefined) continue; + + const price = Math.round(entry.price.rate * 10_000) / 10_000; + const current = result.get(id) ?? {}; + const input = /\sinput\s+tokens$/i.test(entry.name); + const over200k = entry.prompt_tokens === ">200k"; + if (input && over200k) current.inputOver200k = price; + else if (!input && over200k) current.outputOver200k = price; + else if (input) current.input = price; + else current.output = price; + result.set(id, current); + } + return result; +} + +type Modality = "text" | "audio" | "image" | "video" | "pdf"; + +function normalizeModalities(values: string[], fallback: Modality[]): Modality[] { + const allowed = new Set(["text", "audio", "image", "video", "pdf"]); + const normalized = values + .map((value) => value.toLowerCase()) + .map((value) => value === "code" ? "text" : value) + .filter((value): value is Modality => allowed.has(value as Modality)); + return [...new Set(normalized.length > 0 ? normalized : fallback)]; +} + +function number(value: string | number | undefined) { + if (value === undefined) return undefined; + const parsed = typeof value === "number" ? value : Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function inferFamily(id: string, name: string) { + const kimi = inferKimiFamily(id, name); + if (kimi !== undefined) return kimi; + const target = `${id} ${name}`.toLowerCase(); + return [...ModelFamilyValues] + .sort((a, b) => b.length - a.length) + .find((family) => target.includes(family.toLowerCase())); +} + +function cost(model: DigitalOceanSourceModel, existing: ExistingModel | undefined) { + const input = model.pricing?.input ?? existing?.cost?.input; + const output = model.pricing?.output ?? existing?.cost?.output; + if (input === undefined || output === undefined) return existing?.cost; + + const existingTiers = existing?.cost?.tiers ?? []; + const longContext = existingTiers.find((tier) => + (tier.tier.type === undefined || tier.tier.type === "context") && tier.tier.size >= 200_000 + ); + const hasLongContextPricing = model.pricing?.inputOver200k !== undefined + && model.pricing.outputOver200k !== undefined; + const tiers = hasLongContextPricing + ? [ + ...existingTiers.filter((tier) => tier !== longContext), + { + tier: { type: "context" as const, size: longContext?.tier.size ?? 200_000 }, + input: model.pricing!.inputOver200k!, + output: model.pricing!.outputOver200k!, + reasoning: longContext?.reasoning, + cache_read: longContext?.cache_read, + cache_write: longContext?.cache_write, + }, + ] + : existingTiers; + + return { + input, + output, + reasoning: existing?.cost?.reasoning, + cache_read: existing?.cost?.cache_read, + cache_write: existing?.cost?.cache_write, + input_audio: existing?.cost?.input_audio, + output_audio: existing?.cost?.output_audio, + tiers: tiers.length > 0 ? tiers : undefined, + }; +} + +export function buildDigitalOceanModel( + model: DigitalOceanSourceModel, + existing: ExistingModel | undefined, + baseModel = existing === undefined ? resolveDigitalOceanBaseModel(model.id) : existing.base_model, +): SyncedModel { + const input = normalizeModalities( + model.modalities?.input ?? [], + existing?.modalities?.input ?? ["text"], + ); + const output = normalizeModalities( + model.modalities?.output ?? [], + existing?.modalities?.output ?? ["text"], + ); + const context = number(model.context_window) ?? existing?.limit?.context ?? 0; + const maxTokens = model.settings?.find((setting) => setting.name === "max_tokens")?.max; + const limit = { + context, + input: existing?.limit?.input, + output: maxTokens ?? existing?.limit?.output ?? 0, + }; + const textOutput = output.includes("text") && !output.includes("image") && !output.includes("video"); + const reasoning = existing?.reasoning ?? (textOutput && (model.thinking ?? false)); + const releaseDate = existing?.release_date ?? model.created_at?.slice(0, 10) ?? new Date().toISOString().slice(0, 10); + const values: Partial = { + name: model.name, + description: existing?.description ?? describeModel({ + id: model.id, + name: model.name, + family: existing?.family ?? inferFamily(model.id, model.name), + reasoning, + tool_call: existing?.tool_call ?? textOutput, + structured_output: existing?.structured_output, + open_weights: existing?.open_weights ?? false, + limit, + modalities: { input, output }, + }), + family: existing?.family ?? inferFamily(model.id, model.name), + release_date: releaseDate, + last_updated: existing?.last_updated ?? releaseDate, + attachment: existing?.attachment ?? input.some((value) => value !== "text"), + reasoning, + reasoning_options: existing?.reasoning_options, + temperature: existing?.temperature ?? true, + tool_call: existing?.tool_call ?? textOutput, + structured_output: existing?.structured_output, + knowledge: existing?.knowledge, + open_weights: existing?.open_weights ?? false, + status: model.lifecycle_status === "end_of_life" + ? "deprecated" + : existing?.status === "deprecated" ? undefined : existing?.status, + interleaved: existing?.interleaved, + cost: cost(model, existing), + limit, + modalities: { input, output }, + provider: existing?.provider, + experimental: existing?.experimental, + }; + + if (baseModel !== undefined) { + return factorBaseModel(baseModel, { + name: model.name, + description: existing?.description, + attachment: input.some((value) => value !== "text"), + reasoning: model.thinking ?? existing?.reasoning, + reasoning_options: existing?.reasoning_options, + temperature: existing?.temperature, + tool_call: existing?.tool_call, + structured_output: existing?.structured_output, + status: model.lifecycle_status === "end_of_life" + ? "deprecated" + : existing?.status === "deprecated" ? undefined : existing?.status, + interleaved: existing?.interleaved, + cost: cost(model, existing), + limit, + modalities: { input, output }, + provider: existing?.provider, + experimental: existing?.experimental, + }, limit, existing?.base_model_omit); + } + + const required = z.object({ + name: z.string(), + description: z.string(), + release_date: z.string(), + last_updated: z.string(), + attachment: z.boolean(), + reasoning: z.boolean(), + tool_call: z.boolean(), + open_weights: z.boolean(), + cost: z.object({ input: z.number(), output: z.number() }), + limit: z.object({ context: z.number().positive(), output: z.number().positive() }), + modalities: z.object({ input: z.array(z.string()).min(1), output: z.array(z.string()).min(1) }), + }).safeParse(values); + if (!required.success) { + throw new Error(`DigitalOcean model ${model.id} has incomplete metadata required for sync`); + } + return values as SyncedFullModel; +} + +export function resolveDigitalOceanBaseModel(id: string) { + const candidates: string[] = []; + if (id.startsWith("openai-")) candidates.push(`openai/${id.slice("openai-".length)}`); + if (id.startsWith("deepseek-")) { + candidates.push(`deepseek/${id}`); + candidates.push(`deepseek/${id.replace(/^deepseek-4-/, "deepseek-v4-")}`); + } + if (id.startsWith("glm-")) candidates.push(`zai/${id}`); + if (id.startsWith("kimi-")) candidates.push(`moonshotai/${id}`); + if (id.startsWith("minimax-")) candidates.push(`minimax/${id}`); + if (id.startsWith("nvidia-")) candidates.push(`nvidia/${id.slice("nvidia-".length)}`); + if (id.startsWith("alibaba-")) candidates.push(`qwen/${id.slice("alibaba-".length)}`); + if (id.startsWith("qwen")) candidates.push(`qwen/${id}`); + if (id.startsWith("llama")) candidates.push(`meta/${id}`); + if (id.startsWith("mistral") || id.startsWith("ministral")) candidates.push(`mistralai/${id}`); + + const anthropic = id.match(/^anthropic-claude-(\d+(?:\.\d+)?)-(opus|sonnet|haiku)$/); + if (anthropic !== null) { + candidates.push(`anthropic/claude-${anthropic[2]}-${anthropic[1]}`); + } + if (id.startsWith("anthropic-")) candidates.push(`anthropic/${id.slice("anthropic-".length)}`); + + for (const candidate of candidates) { + const resolved = resolveCanonicalBaseModel(candidate); + if (resolved !== undefined) return resolved; + } + return undefined; +} diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 964a15ba09..89f3cd4c34 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -10,6 +10,14 @@ import { type AnthropicModel, } from "../src/sync/providers/anthropic.js"; import { buildDeepInfraModel, type DeepInfraModel } from "../src/sync/providers/deepinfra.js"; +import { + buildDigitalOceanModel, + digitalocean, + fetchDigitalOceanModels, + parseDigitalOceanModels, + resolveDigitalOceanBaseModel, + type DigitalOceanSourceModel, +} from "../src/sync/providers/digitalocean.js"; import { buildOpenRouterModel, openrouter, type OpenRouterModel } from "../src/sync/providers/openrouter.js"; import { buildLLMGatewayModel, type LLMGatewayModel } from "../src/sync/providers/llmgateway.js"; @@ -120,6 +128,161 @@ test("labels Anthropic aliases as latest", () => { expect(model.name).toBe("Claude Sonnet 5 (latest)"); }); +function digitalOceanModel(overrides: Partial = {}): DigitalOceanSourceModel { + return { + id: "anthropic-claude-4.6-sonnet", + name: "Claude Sonnet 4.6", + lifecycle_status: "available", + type: "chat", + thinking: true, + context_window: 1_000_000, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + settings: [{ name: "max_tokens", max: 64_000 }], + created_at: "2026-02-17T00:00:00Z", + pricing: { + input: 3, + output: 15, + inputOver200k: 6, + outputOver200k: 22.5, + }, + ...overrides, + }; +} + +test("syncs DigitalOcean pricing and preserves curated cache tiers", () => { + const model = buildDigitalOceanModel(digitalOceanModel(), { + name: "Claude Sonnet 4.6", + description: "Curated DigitalOcean description", + family: "claude-sonnet", + release_date: "2026-02-17", + last_updated: "2026-03-13", + attachment: true, + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }], + temperature: true, + tool_call: true, + open_weights: false, + status: "beta", + cost: { + input: 2, + output: 10, + cache_read: 0.3, + cache_write: 3.75, + tiers: [{ + tier: { type: "context", size: 200_000 }, + input: 4, + output: 15, + cache_read: 0.6, + cache_write: 7.5, + }], + }, + limit: { context: 200_000, output: 64_000 }, + modalities: { input: ["text", "image", "pdf"], output: ["text"] }, + }); + + expect(model).toMatchObject({ + description: "Curated DigitalOcean description", + last_updated: "2026-03-13", + status: "beta", + cost: { + input: 3, + output: 15, + cache_read: 0.3, + cache_write: 3.75, + tiers: [{ + tier: { type: "context", size: 200_000 }, + input: 6, + output: 22.5, + cache_read: 0.6, + cache_write: 7.5, + }], + }, + limit: { context: 1_000_000, output: 64_000 }, + }); +}); + +test("filters unmanaged DigitalOcean models and joins pricing names", () => { + const models = parseDigitalOceanModels({ + models: [ + digitalOceanModel({ id: "kimi-k2.5", name: "Kimi K2", pricing: undefined }), + digitalOceanModel({ + id: "bge-m3", + name: "BGE M3", + type: "embedding", + modalities: { input: ["text"], output: ["text"] }, + pricing: undefined, + }), + ], + pricing: [ + { name: "Kimi K2.5 Input Tokens", slug: "input", model: "DigitalOcean-Hosted Models", price: { rate: 0.5 } }, + { name: "Kimi K2.5 Output Tokens", slug: "output", model: "DigitalOcean-Hosted Models", price: { rate: 2.4 } }, + ], + }); + + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "kimi-k2.5", + pricing: { input: 0.5, output: 2.4 }, + }); +}); + +test("resolves DigitalOcean IDs to canonical model metadata", () => { + expect(resolveDigitalOceanBaseModel("openai-gpt-5.5")).toBe("openai/gpt-5.5"); + expect(resolveDigitalOceanBaseModel("deepseek-v4-pro")).toBe("deepseek/deepseek-v4-pro"); +}); + +test("new DigitalOcean base models inherit intrinsic capabilities", () => { + const model = buildDigitalOceanModel( + digitalOceanModel({ + id: "openai-gpt-5.5", + name: "GPT-5.5", + thinking: undefined, + }), + undefined, + "openai/gpt-5.5", + ); + + expect(model).toMatchObject({ base_model: "openai/gpt-5.5" }); + expect(model).not.toHaveProperty("open_weights"); + expect(model).not.toHaveProperty("family"); + expect(model).not.toHaveProperty("release_date"); + expect(model).not.toHaveProperty("knowledge"); + expect(model).not.toHaveProperty("reasoning"); + expect(model).not.toHaveProperty("temperature"); +}); + +test("skips new DigitalOcean models with incomplete pricing or limits", () => { + const translated = digitalocean.translateModel( + digitalOceanModel({ pricing: undefined }), + { existing: () => undefined, authored: () => undefined }, + ); + expect(translated).toBeUndefined(); +}); + +test("fetches every page of the DigitalOcean catalog", async () => { + const requests: string[] = []; + const first = digitalOceanModel({ id: "first", pricing: undefined }); + const second = digitalOceanModel({ id: "second", pricing: undefined }); + const fetcher = ((input: string | URL | Request) => { + const url = String(input); + requests.push(url); + if (url.includes("static-content")) { + return Promise.resolve(new Response(JSON.stringify({ gradient: { models: [] } }))); + } + if (url.includes("?page=2")) { + return Promise.resolve(new Response(JSON.stringify({ models: [second] }))); + } + return Promise.resolve(new Response(JSON.stringify({ + models: [first], + links: { pages: { next: "https://api.digitalocean.com/v2/gen-ai/models?page=2" } }, + }))); + }) as typeof fetch; + + const result = await fetchDigitalOceanModels("test-key", fetcher); + expect(result.models.map((model) => model.id)).toEqual(["first", "second"]); + expect(requests).toHaveLength(3); +}); + function deepInfraModel(model_name: string, tags: string[]): DeepInfraModel { return { model_name, diff --git a/providers/digitalocean/models/openai-gpt-5.4-mini.toml b/providers/digitalocean/models/openai-gpt-5.4-mini.toml index fe1776e652..185c816afd 100644 --- a/providers/digitalocean/models/openai-gpt-5.4-mini.toml +++ b/providers/digitalocean/models/openai-gpt-5.4-mini.toml @@ -1,9 +1,10 @@ -name = "GPT-5.4 mini" -description = "Compact GPT model for low-latency assistance and high-volume workloads" # Serverless supports only POST `/v1/responses`; use # `reasoning.effort = none|low|medium|high|xhigh`. # https://docs.digitalocean.com/products/inference/details/models/index.html.md (accessed 2026-06-25) # https://developers.openai.com/api/docs/models/gpt-5.4-mini (accessed 2026-06-25) + +name = "GPT-5.4 mini" +description = "Compact GPT model for low-latency assistance and high-volume workloads" family = "gpt-mini" release_date = "2026-03-17" last_updated = "2026-03-17" diff --git a/providers/digitalocean/models/openai-gpt-5.4-nano.toml b/providers/digitalocean/models/openai-gpt-5.4-nano.toml index 62f6ca8fbc..017b967560 100644 --- a/providers/digitalocean/models/openai-gpt-5.4-nano.toml +++ b/providers/digitalocean/models/openai-gpt-5.4-nano.toml @@ -1,9 +1,10 @@ -name = "GPT-5.4 nano" -description = "Compact GPT model for low-latency assistance and high-volume workloads" # Serverless supports only POST `/v1/responses`; use # `reasoning.effort = none|low|medium|high|xhigh`. # https://docs.digitalocean.com/products/inference/details/models/index.html.md (accessed 2026-06-25) # https://developers.openai.com/api/docs/models/gpt-5.4-nano (accessed 2026-06-25) + +name = "GPT-5.4 nano" +description = "Compact GPT model for low-latency assistance and high-volume workloads" family = "gpt-nano" release_date = "2026-03-17" last_updated = "2026-03-17" diff --git a/providers/digitalocean/models/openai-gpt-5.4-pro.toml b/providers/digitalocean/models/openai-gpt-5.4-pro.toml index 73ec430512..7f5539ccc7 100644 --- a/providers/digitalocean/models/openai-gpt-5.4-pro.toml +++ b/providers/digitalocean/models/openai-gpt-5.4-pro.toml @@ -1,9 +1,10 @@ -name = "GPT-5.4 pro" -description = "Frontier GPT model for professional reasoning, coding, and multimodal work" # Serverless supports only POST `/v1/responses`; this model restricts # `reasoning.effort` to medium|high|xhigh. # https://docs.digitalocean.com/products/inference/details/models/index.html.md (accessed 2026-06-25) # https://developers.openai.com/api/docs/models/gpt-5.4-pro (accessed 2026-06-25) + +name = "GPT-5.4 pro" +description = "Frontier GPT model for professional reasoning, coding, and multimodal work" family = "gpt-pro" release_date = "2026-03-05" last_updated = "2026-03-05" diff --git a/providers/digitalocean/models/openai-gpt-5.4.toml b/providers/digitalocean/models/openai-gpt-5.4.toml index ea54d148ac..a492063344 100644 --- a/providers/digitalocean/models/openai-gpt-5.4.toml +++ b/providers/digitalocean/models/openai-gpt-5.4.toml @@ -1,9 +1,10 @@ -name = "GPT-5.4" -description = "Frontier GPT model for professional reasoning, coding, and multimodal work" # Serverless supports only POST `/v1/responses`; use # `reasoning.effort = none|low|medium|high|xhigh`. # https://docs.digitalocean.com/products/inference/details/models/index.html.md (accessed 2026-06-25) # https://developers.openai.com/api/docs/models/gpt-5.4 (accessed 2026-06-25) + +name = "GPT-5.4" +description = "Frontier GPT model for professional reasoning, coding, and multimodal work" family = "gpt" release_date = "2026-03-05" last_updated = "2026-03-05" diff --git a/providers/digitalocean/models/openai-gpt-5.5.toml b/providers/digitalocean/models/openai-gpt-5.5.toml index 25c349869f..657a01550e 100644 --- a/providers/digitalocean/models/openai-gpt-5.5.toml +++ b/providers/digitalocean/models/openai-gpt-5.5.toml @@ -1,9 +1,10 @@ -name = "GPT-5.5" -description = "Frontier GPT model for professional reasoning, coding, and multimodal work" # Serverless supports only POST `/v1/responses`; use # `reasoning.effort = none|low|medium|high|xhigh`. # https://docs.digitalocean.com/products/inference/details/models/index.html.md (accessed 2026-06-25) # https://developers.openai.com/api/docs/models/gpt-5.5 (accessed 2026-06-25) + +name = "GPT-5.5" +description = "Frontier GPT model for professional reasoning, coding, and multimodal work" family = "gpt" release_date = "2026-04-23" last_updated = "2026-04-30" diff --git a/sync.md b/sync.md index c0e92693b0..b14bc525fa 100644 --- a/sync.md +++ b/sync.md @@ -14,6 +14,7 @@ The grouped sync targets are available for local convenience, but CI syncs each - `bun models:sync cloudflare` syncs the Cloudflare sync group. - `bun models:sync direct` syncs every provider in the `direct` group. - `bun models:sync google` syncs only Google. +- `bun models:sync digitalocean` syncs only DigitalOcean. - `bun models:sync xai` syncs only xAI. - `bun models:sync aggregators --dry-run` prints changes without writing model files. - `bun models:sync aggregators --new-only` creates new model files but skips updates and removals. @@ -168,6 +169,14 @@ OVHcloud AI Endpoints is implemented in `packages/core/src/sync/providers/ovhclo - `attachment` is derived from non-text `input_modalities`, and `open_weights` from the presence of `hugging_face_id`. - `release_date`/`last_updated` default to the catalog `created` timestamp but preserve any existing hand-authored dates; `knowledge`, `family`, `status`, `interleaved`, and `limit.input` are preserved when present. +## DigitalOcean Notes + +- DigitalOcean is implemented in `packages/core/src/sync/providers/digitalocean.ts`. +- Source endpoints: `https://api.digitalocean.com/v2/gen-ai/models` for catalog metadata and `https://www.digitalocean.com/api/static-content/v1/products` for pricing. +- Required auth: `DIGITALOCEAN_API_TOKEN` or `DIGITALOCEAN_ACCESS_TOKEN`; the pricing endpoint is public. +- The sync manages text-output models. Other model types and local models absent from the API are retained for manual lifecycle review. +- Catalog metadata updates names, modalities, limits, and end-of-life status. Pricing updates input/output and long-context rates while preserving cache, reasoning, and audio prices that the pricing API does not expose. + ## Vercel Status Vercel is intentionally not wired into `bun models:sync` right now. Keep using the existing `vercel:generate` script until Vercel sync behavior is redesigned and reviewed separately.