Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ ALLOW_INSECURE_SMTP=false
# Local/private provider endpoints are rejected unless explicitly enabled.
ALLOW_INSECURE_PROVIDER_BASE_URL=false
ALLOW_PRIVATE_PROVIDER_BASE_URL=false
# Admin uploads of provider plugin code packages (POST /api/admin/plugins/upload)
# are denied until explicitly enabled. When enabled, the API validates the .mjs
# artifact and stores it in the configured object storage bucket; the worker then
# pulls it back out and dynamically imports it. Both services need the flag: the
# API to gate the endpoint, the worker to decide whether to refresh at all.
ALLOW_PLUGIN_UPLOAD=false
# Optional override for the worker's local plugin artifact cache directory inside
# its container. Compose already defaults it to /tmp/musecanvas-plugin-cache; it
# must never live under /app (an ephemeral image layer, shared with no other
# service — api and worker run from separate images with no shared volume).
# PLUGIN_CACHE_DIR=/tmp/musecanvas-plugin-cache

# ===== Upgrade-only legacy crypto (optional, one release) =====
# Read-only fallback for rows encrypted before the APP_MASTER_KEY rollout:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/media-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ jobs:
run: pnpm --filter @musecanvas/providers test

- name: Test media provider contract integration (no external services)
run: pnpm --filter @musecanvas/providers exec tsx --test ../../tests/integration/media-provider-contract.test.ts
# Quoted glob, not a filename: this step used to name
# media-provider-contract.test.ts specifically, so any test added under
# tests/integration/ was silently never run in CI. The quotes are load
# bearing — the runner, not the shell, expands the pattern.
run: pnpm --filter @musecanvas/providers exec tsx --test "../../tests/integration/*.test.ts"

- name: Test domain package (no external services)
run: pnpm --filter @musecanvas/domain test
Expand Down
571 changes: 17 additions & 554 deletions apps/api/app/api/[...path]/route.ts

Large diffs are not rendered by default.

241 changes: 133 additions & 108 deletions apps/api/src/admin/model-presets.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
import { resolveCatalogPlugin } from '../modules/admin/plugin-catalog'
import type { JsonValue, MediaParameterProvenance, ModelCapabilities } from '@musecanvas/contracts'
import { validateModelCapabilities } from '@musecanvas/contracts'

export type ReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'xhigh'
export type LanguageProtocol = 'openai_chat' | 'openai_responses' | 'anthropic_messages'

export type ImageModelPreset = {
/**
* A media preset is an **identity**, never a capability set.
*
* Until now these presets carried `sizes`, `qualityOptions`, `maxCount`,
* `maxInputImages`, `modes`, `parameters`, `inputSlots` and `defaults` — a
* hand-maintained transcription of what the OpenAI, Seedream, Seedance and Veo
* plugins already declare in their own manifests. Two copies of one fact is the
* bug: the preset list offered `1024x1024` to Seedream 4.5 while the vendor
* band starts at 2K, the generic video preset offered 1-60s durations while
* Seedance caps at 30, and nothing above `capabilities` could ever be trusted to
* be the plugin's answer rather than the host's guess.
*
* The contract now lives in exactly one place — the plugin manifest — and
* `resolvePresetCapabilities` is the only way to read it. A preset that cannot
* resolve to a declared model declares nothing.
*/
export type MediaModelPreset = {
id: string
modelKind: 'image'
modelKind: 'image' | 'video'
displayName: string
adapter: 'openai' | 'seedream'
providerId: 'openai' | 'volcengine'
pluginId: 'openai-image' | 'seedream-image'
/**
* @deprecated Mirrors the legacy `model_configs.adapter` column, which is a
* routing label from before plugins existed and has no descriptor equivalent.
* Never a capability, and no new reader should consume it.
*/
adapter?: string
providerId: string
pluginId: string
pluginVersion: string
vendorModelId: string
baseUrl: string
sizes: string[]
qualityOptions: string[]
maxCount: number
maxInputImages: number
concurrencyLimit: number
watermark: boolean
}

export type ImageModelPreset = MediaModelPreset & { modelKind: 'image' }
export type VideoModelPreset = MediaModelPreset & { modelKind: 'video' }

export type LanguageModelPreset = {
id: string
modelKind: 'language'
displayName: string
/** @deprecated Legacy `model_configs.adapter` column; language models have no plugin manifest. */
adapter: 'openai' | 'anthropic'
vendorModelId: string
baseUrl: string
Expand All @@ -33,125 +57,40 @@ export type LanguageModelPreset = {
concurrencyLimit: number
}

export type VideoParameterDescriptor =
| { type: 'enum'; name: string; label?: string; options: string[]; defaultValue?: string; required?: boolean }
| { type: 'integer'; name: string; label?: string; min?: number; max?: number; defaultValue?: number; required?: boolean }
| { type: 'boolean'; name: string; label?: string; defaultValue?: boolean; required?: boolean }
| { type: 'text'; name: string; label?: string; maxLength?: number; defaultValue?: string; required?: boolean }

export type VideoInputSlotDescriptor = {
role: 'first_frame' | 'last_frame' | 'reference_image' | 'prompt_image' | string
required: boolean
minCount: number
maxCount: number
allowedMediaKinds: ('image' | 'video')[]
label?: string
}

export type VideoModelPreset = {
id: string
modelKind: 'video'
displayName: string
providerId: string
pluginId: string
pluginVersion: string
vendorModelId: string
baseUrl: string
modes: ('text_to_video' | 'image_to_video')[]
parameters: VideoParameterDescriptor[]
inputSlots: VideoInputSlotDescriptor[]
defaults: Record<string, string | number | boolean>
maxCount: number
concurrencyLimit: number
}

export type ModelPreset = ImageModelPreset | LanguageModelPreset | VideoModelPreset

const seedream1kWay2Sizes = [
'1024x1024', '1152x864', '864x1152', '1280x720', '720x1280', '1248x832', '832x1248', '1512x648',
]
const seedream2kWay2Sizes = [
'2048x2048', '2304x1728', '1728x2304', '2848x1600', '1600x2848', '2496x1664', '1664x2496', '3136x1344',
]
const seedream4kWay2Sizes = [
'4096x4096', '4704x3520', '3520x4704', '5504x3040', '3040x5504', '4992x3328', '3328x4992', '6240x2656',
]
const seedream40Way2Sizes = [...seedream1kWay2Sizes, ...seedream2kWay2Sizes, ...seedream4kWay2Sizes]
const seedream45Way2Sizes = [...seedream2kWay2Sizes, ...seedream4kWay2Sizes]

// Seedance validates durationSeconds as a number in [1, 30]; the old generic
// 1-60 integer range contradicted the plugin, so this preset pins 1-30 here.
const seedanceDurationParameter: VideoParameterDescriptor = {
type: 'integer', name: 'durationSeconds', label: '时长(秒)', min: 1, max: 30, defaultValue: 5, required: false,
}
// Veo only accepts durations 4/6/8. Enum strings keep the descriptor
// serializable; request normalization Number-converts them before validation.
const veoDurationParameter: VideoParameterDescriptor = {
type: 'enum', name: 'durationSeconds', label: '时长(秒)', options: ['4', '6', '8'], defaultValue: '8', required: false,
}
// Veo only accepts 16:9 and 9:16; other ratios are normalized or rejected.
const veoAspectParameter: VideoParameterDescriptor = {
type: 'enum', name: 'aspectRatio', label: '宽高比',
options: ['16:9', '9:16'], defaultValue: '16:9', required: false,
}
// Veo resolutions are 720p/1080p/4k; 1080p+ requires the standard model at 8s.
const veoResolutionParameter: VideoParameterDescriptor = {
type: 'enum', name: 'resolution', label: '分辨率',
options: ['720p', '1080p', '4k'], defaultValue: '1080p', required: false,
}
const videoAspectParameter: VideoParameterDescriptor = {
type: 'enum', name: 'aspectRatio', label: '宽高比',
options: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], defaultValue: '16:9', required: false,
}
const videoResolutionParameter: VideoParameterDescriptor = {
type: 'enum', name: 'resolution', label: '分辨率',
options: ['720p', '1080p'], defaultValue: '720p', required: false,
}
const videoAudioParameter: VideoParameterDescriptor = {
type: 'boolean', name: 'audio', label: '生成音频', defaultValue: true, required: false,
}
const videoCountParameter: VideoParameterDescriptor = {
type: 'integer', name: 'count', label: '生成数量', min: 1, max: 4, defaultValue: 1, required: false,
}
const videoFrameSlots: VideoInputSlotDescriptor[] = [
{ role: 'first_frame', required: false, minCount: 0, maxCount: 1, allowedMediaKinds: ['image'], label: '首帧' },
{ role: 'last_frame', required: false, minCount: 0, maxCount: 1, allowedMediaKinds: ['image'], label: '尾帧' },
{ role: 'reference_image', required: false, minCount: 0, maxCount: 4, allowedMediaKinds: ['image'], label: '参考图' },
]
export type ModelPreset = ImageModelPreset | VideoModelPreset | LanguageModelPreset

/**
* Host-slug preset ids are pinned by `packages/database/src/migrate.ts`
* eligibility checks (`WHERE preset_id = 'openai-gpt-image-2' …`) and by
* `presetMatchesPersistedModel`, which compares a stored `preset_id` against the
* row's plugin identity. Renaming one is a data migration, not a refactor, so
* these ids are stable API: only their *contents* became identity-only.
*/
export const modelPresets: ModelPreset[] = [
{
modelKind: 'image',
id: 'openai-gpt-image-2', displayName: 'GPT Image 2', adapter: 'openai', providerId: 'openai', pluginId: 'openai-image', pluginVersion: '1.1.0', vendorModelId: 'gpt-image-2', baseUrl: 'https://api.openai.com',
sizes: ['1024x1024', '1280x720', '720x1280', '1536x1024', '1024x1536'], qualityOptions: ['auto', 'low', 'medium', 'high'], maxCount: 4, maxInputImages: 4, concurrencyLimit: 1, watermark: false,
concurrencyLimit: 1,
},
{
modelKind: 'image',
id: 'seedream-4-0', displayName: 'Seedream 4.0', adapter: 'seedream', providerId: 'volcengine', pluginId: 'seedream-image', pluginVersion: '1.1.0', vendorModelId: 'doubao-seedream-4-0-250828', baseUrl: 'https://ark.cn-beijing.volces.com',
sizes: seedream40Way2Sizes, qualityOptions: [], maxCount: 4, maxInputImages: 4, concurrencyLimit: 1, watermark: false,
concurrencyLimit: 1,
},
{
modelKind: 'image',
id: 'seedream-4-5', displayName: 'Seedream 4.5', adapter: 'seedream', providerId: 'volcengine', pluginId: 'seedream-image', pluginVersion: '1.1.0', vendorModelId: 'doubao-seedream-4-5-251128', baseUrl: 'https://ark.cn-beijing.volces.com',
sizes: seedream45Way2Sizes, qualityOptions: [], maxCount: 4, maxInputImages: 4, concurrencyLimit: 1, watermark: false,
concurrencyLimit: 1,
},
{
modelKind: 'video',
id: 'seedance-1-0', displayName: 'Seedance 2.0 Fast', providerId: 'volcengine', pluginId: 'seedance-video', pluginVersion: '1.0.0', vendorModelId: 'doubao-seedance-2-0-fast-260128', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
modes: ['text_to_video', 'image_to_video'],
parameters: [seedanceDurationParameter, videoAspectParameter, videoResolutionParameter, videoAudioParameter, videoCountParameter],
inputSlots: videoFrameSlots,
defaults: { durationSeconds: 5, aspectRatio: '16:9', resolution: '720p', audio: true, count: 1 },
maxCount: 4, concurrencyLimit: 1,
concurrencyLimit: 1,
},
{
modelKind: 'video',
id: 'veo-3-1', displayName: 'Veo 3.1', providerId: 'google', pluginId: 'veo-video', pluginVersion: '1.0.0', vendorModelId: 'veo-3.1-generate-001', baseUrl: 'https://us-central1-aiplatform.googleapis.com',
modes: ['text_to_video', 'image_to_video'],
parameters: [veoDurationParameter, veoAspectParameter, veoResolutionParameter, videoAudioParameter, videoCountParameter],
inputSlots: videoFrameSlots,
defaults: { durationSeconds: 8, aspectRatio: '16:9', resolution: '1080p', audio: true, count: 1 },
maxCount: 4, concurrencyLimit: 1,
concurrencyLimit: 1,
},
{
id: 'openai-gpt-5-5', modelKind: 'language', displayName: 'GPT-5.5', adapter: 'openai', vendorModelId: 'gpt-5.5', baseUrl: 'https://api.openai.com',
Expand All @@ -162,3 +101,89 @@ export const modelPresets: ModelPreset[] = [
languageProtocol: 'openai_responses', maxOutputTokens: 25000, reasoningEffort: 'medium', concurrencyLimit: 1,
},
]

export type ResolvedPresetCapabilities = {
/**
* The manifest's own contract, rebuilt through `validateModelCapabilities` so
* nothing outside the known descriptor grammar survives into a revision.
* Empty arrays plus `declaredBy: 'undeclared'` when the model declares nothing.
*/
capabilities: ModelCapabilities
/** Starting values as declared next to the contract; never invented here. */
defaults: Record<string, JsonValue>
/** Vendor-retired but still servable. */
deprecated: boolean
deprecationNote?: string
/**
* Why a declaration was refused. Present only when the manifest *did* declare
* something and that something was malformed: an absent contract is not an
* error, it is an absence, and the admin has to be able to tell them apart.
*/
findings?: Array<{ rule: string; message: string }>
}

/** The one answer a model with no declaration gets: nothing, and a label saying so. */
function undeclaredContract(): ModelCapabilities {
return {
modes: [],
parameters: [],
inputSlots: [],
maxCount: 0,
supportedMediaKinds: [],
declaredBy: 'undeclared',
}
}

/**
* The plugin manifest is the only source of a model's parameter contract, so
* that is the only thing this lookup reads.
*
* `declaredBy` is stamped `plugin-manifest` when the model declared a contract
* without saying where it came from — the manifest *is* the source, so stating
* it is a fact rather than a guess. Anything the manifest does not say stays
* unsaid: an unknown `pluginId`, a plugin the catalog has not activated, a
* non-media manifest, a vendor model the manifest does not list, and a model
* with no `capabilities` block all resolve to `undeclared` with empty arrays.
*
* A declaration that exists but is structurally illegal (an unknown descriptor
* type, a preset outside its own geometry band, a default nobody offers) also
* resolves to `undeclared`, but carries `findings` so the write path can refuse
* the save with the plugin's own error instead of quietly persisting nothing.
*/
export async function resolvePresetCapabilities(
pluginId: string,
pluginVersion: string,
vendorModelId: string,
): Promise<ResolvedPresetCapabilities> {
const catalog = await resolveCatalogPlugin(pluginId, pluginVersion)
if (!catalog || catalog.manifest.kind !== 'media') {
return { capabilities: undeclaredContract(), defaults: {}, deprecated: false }
}
const model = (catalog.manifest.models ?? []).find(entry => entry.id === vendorModelId)
const declared = model?.capabilities
if (!model || !declared) {
return { capabilities: undeclaredContract(), defaults: {}, deprecated: false }
}
const provenance: MediaParameterProvenance = declared.declaredBy ?? 'plugin-manifest'
const validated = validateModelCapabilities({
...declared,
declaredBy: provenance,
...(typeof model.deprecated === 'boolean' ? { deprecated: model.deprecated } : {}),
...(typeof model.deprecationNote === 'string' ? { deprecationNote: model.deprecationNote } : {}),
})
if (!validated.ok || !validated.capabilities) {
return {
capabilities: { ...undeclaredContract(), declaredBy: 'undeclared' },
defaults: {},
deprecated: model.deprecated === true,
...(typeof model.deprecationNote === 'string' ? { deprecationNote: model.deprecationNote } : {}),
findings: validated.findings,
}
}
return {
capabilities: validated.capabilities,
defaults: { ...(model.defaults ?? {}) },
deprecated: model.deprecated === true,
...(typeof model.deprecationNote === 'string' ? { deprecationNote: model.deprecationNote } : {}),
}
}
Loading
Loading