OpenMAIC Documentation

Open Multi-Agent Interactive Classroom — Get an immersive, multi-agent learning experience in just one click

41,147 stars TypeScript TypeScript 1 file · ~221,826 tokens
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy OpenMAIC on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name openmaic -p 3000:3000 --restart always postgres:16
Configured Services & Ports
⚙️ openmaic
image: build locally
🔌 Ports: ${OPENMAIC_PUBLISH_ADDRESS:-127.0.0.1}:${OPENMAIC_PORT:-3000}:3000
⚙️ postgres
image: postgres:16
⚙️ render-service
image: build locally
docker-compose.yml
yaml (docker-compose.yml)
services:
  openmaic:
    build:
      context: .
      args:
        - ALPINE_MIRROR=${ALPINE_MIRROR:-}
        - NPM_REGISTRY=${NPM_REGISTRY:-}
        # NEXT_PUBLIC_* values are compiled into the browser bundle. Empty values
        # follow each feature's documented default; explicit build-time overrides
        # can be supplied on the command line.
        # env_file below does not feed build args.
        - ALLOWED_FRAME_ANCESTORS=${ALLOWED_FRAME_ANCESTORS:-}
        - NEXT_PUBLIC_MAIC_EDITOR_ENABLED=${NEXT_PUBLIC_MAIC_EDITOR_ENABLED:-}
        - NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED=${NEXT_PUBLIC_MAIC_EDITOR_RENDERER_ENABLED:-}
        - NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED=${NEXT_PUBLIC_MAIC_PLAYBACK_RENDERER_ENABLED:-}
        - NEXT_PUBLIC_PI_CHAT_ENABLED=${NEXT_PUBLIC_PI_CHAT_ENABLED:-}
        - NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED=${NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED:-}
        - NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI=${NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI:-}
        - NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=${NEXT_PUBLIC_ENABLE_VIDEO_EXPORT:-}
        - NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION=${NEXT_PUBLIC_VIDEO_EXPORT_CTA_DESTINATION:-}
        - NEXT_PUBLIC_ENABLE_PPTX_IMPORT=${NEXT_PUBLIC_ENABLE_PPTX_IMPORT:-}
        - NEXT_PUBLIC_PRO_WORKBENCH_ENABLED=${NEXT_PUBLIC_PRO_WORKBENCH_ENABLED:-}
    # Published on loopback by default: the single-user owner (see
    # docker-compose.defaults.env) serves one course library to every request,
    # so only this machine should reach it. To publish on the network, set
    # ACCESS_CODE in .env.local (strongly recommended: without it anyone who
    # reaches the port shares, edits and can delete the library) and start with
    # OPENMAIC_PUBLISH_ADDRESS=0.0.0.0 (or a specific interface address).
    ports:
      - '${OPENMAIC_PUBLISH_ADDRESS:-127.0.0.1}:${OPENMAIC_PORT:-3000}:3000'
    env_file:
      # Compose's defaults for this deployment (the bundled DATABASE_URL, the
      # single-user owner); .env.local is read after it, so a key set there
      # (for example an external DATABASE_URL) overrides the default.
      - docker-compose.defaults.env
      - .env.local
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      # The address the port above is published on, passed in so the app can
      # warn when it is published beyond loopback with the default PostgreSQL
      # password below.
      - OPENMAIC_PUBLISH_ADDRESS=${OPENMAIC_PUBLISH_ADDRESS:-127.0.0.1}
      # Point the app at the render service when it's running. This wins over any
      # RENDER_SERVICE_URL in .env.local; the service only starts under the
      # "video-export" profile. The app's capability check probes the service's
      # /health, so if the profile is off (service absent) the app reports the
      # MP4 export disabled and degrades to the ZIP-download path — this URL
      # being set does not, by itself, advertise a working render.
      - RENDER_SERVICE_URL=http://render-service:9000
    networks:
      - default # outbound internet (LLM/media provider APIs, etc.)
      - render # reach the render service over the isolated network
    volumes:
      # Optional: the model configuration (copy openmaic.example.yml to
      # openmaic.yml first; a missing file would be mounted as a directory).
      # - ./openmaic.yml:/app/openmaic.yml:ro
      # Legacy, read only when there is no openmaic.yml:
      # - ./server-providers.yml:/app/server-providers.yml:ro
      - openmaic-data:/app/data
    restart: unless-stopped

  # PostgreSQL for the app-embedded persistence API. Started by default; the
  # app waits for its health check. Its port is not published to the host here;
  # `pnpm db:up` runs this service definition as a separate development
  # database (its own project and volume, see docker-compose.db.yml).
  postgres:
    image: postgres:16
    environment:
      - POSTGRES_DB=openmaic
      - POSTGRES_USER=openmaic
      # Development default. It initializes the role only when the volume is
      # empty; the default DATABASE_URL in docker-compose.defaults.env is built
      # from the same variable, so use letters and digits only (or set an
      # encoded DATABASE_URL in .env.local). Set PERSISTENCE_POSTGRES_PASSWORD before
      # publishing the app beyond loopback; the app warns at startup if it is
      # published elsewhere with this default.
      - POSTGRES_PASSWORD=${PERSISTENCE_POSTGRES_PASSWORD:-openmaic-dev}
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U openmaic -d openmaic']
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 5s
    volumes:
      - openmaic-postgres:/var/lib/postgresql/data
    restart: unless-stopped

  # Isolated MP4 render service (Node 22 + Chromium + FFmpeg) for classroom video
  # export (issue #866). Opt-in: only starts with `--profile video-export`, since
  # the image is heavy (bundles Chromium + FFmpeg) and the capability is optional.
  #
  #   docker compose --profile video-export up --build
  #
  # Sits on the `render` network (`internal: true`, no host/internet gateway).
  # It shares that network with `openmaic` only so the app can reach it; to stop
  # the untrusted Chromium from initiating connections *back* to the app over
  # that shared network, the container's entrypoint installs an iptables egress
  # lockdown (needs CAP_NET_ADMIN) that drops all outbound except loopback and
  # replies on app-initiated connections. The export ZIP is self-contained, so
  # the render needs no outbound at all.
  render-service:
    build: ./render-service
    profiles:
      - video-export
    expose:
      - '9000'
    # Required for the entrypoint's iptables egress lockdown. Without it the
    # service still boots, but logs a warning and does NOT block Chromium egress.
    cap_add:
      - NET_ADMIN
    environment:
      - PORT=9000
      # The standard CPU profile prefers BeginFrame but permits producer's
      # compatibility fallback (for example iframe GenUI). It still fixes one
      # producer worker, one render, and one extraction. Select low-memory to
      # force screenshot instead of relying on producer auto-selection.
      - RENDER_RESOURCE_PROFILE=${RENDER_RESOURCE_PROFILE:-standard}
      - RENDER_CHUNK_EXECUTION=${RENDER_CHUNK_EXECUTION:-false}
      - RENDER_CHUNK_COUNT=${RENDER_CHUNK_COUNT:-1}
      - RENDER_CHUNK_WORKERS=${RENDER_CHUNK_WORKERS:-1}
      - RENDER_MAX_PARALLEL_CHUNKS=${RENDER_MAX_PARALLEL_CHUNKS:-1}
      - RENDER_CHUNK_SIZE_FRAMES=${RENDER_CHUNK_SIZE_FRAMES:-0}
      - RENDER_TARGET_CHUNK_FRAMES=${RENDER_TARGET_CHUNK_FRAMES:-0}
      - PRODUCER_HEADLESS_SHELL_PATH=/usr/bin/chromium-headless-shell
      - RENDER_MAX_CONCURRENCY=1
      - RENDER_MAX_CONCURRENT_EXTRACTIONS=1
      # Long compositions still need CDP headroom even with one explicit worker.
      - PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS=900000
      # OpenMAIC's long slide exports currently exhaust producer's 15s static
      # verification budget and disable dedup anyway. Skip that guaranteed-lost
      # startup cost until the verifier can share/scale its plan across workers.
      - HF_STATIC_DEDUP=false
      # In this default topology the app is exposed directly and does NOT set
      # TRUST_PROXY_HEADERS, so every caller collapses to one identity. A
      # per-identity limit would then throttle the whole deployment to a single
      # render, so disable it here and rely on RENDER_MAX_CONCURRENCY + the global
      # RENDER_MAX_QUEUE cap. Set this >0 only with a trusted proxy supplying
      # per-user identity (see render-service/README.md).
      - RENDER_MAX_JOBS_PER_USER=0
      - RENDER_PREVIEW_TIMEOUT_MS=${RENDER_PREVIEW_TIMEOUT_MS:-20000}
      # Separate Chromium capacity: previews can run alongside a video export.
      - RENDER_PREVIEW_MAX_CONCURRENCY=${RENDER_PREVIEW_MAX_CONCURRENCY:-1}
      - RENDER_PREVIEW_MAX_IN_FLIGHT=${RENDER_PREVIEW_MAX_IN_FLIGHT:-8}
      # Preview callers send a durable owner identity in x-openmaic-client, so
      # keep this per-owner guard enabled. Set it to 0 only in deployments that
      # call /preview without an owner identity.
      - RENDER_PREVIEW_MAX_PER_USER=${RENDER_PREVIEW_MAX_PER_USER:-2}
    # Bound RAM for the export (Chromium + FFmpeg + archive expansion) and the
    # independent preview browsers. Provision more before raising preview concurrency.
    # The standard profile requires 8 GiB. For the explicit low-memory profile,
    # set RENDER_RESOURCE_PROFILE=low-memory and RENDER_SERVICE_MEMORY_LIMIT=4g.
    mem_limit: ${RENDER_SERVICE_MEMORY_LIMIT:-8g}
    # Chromium media/frame work can exceed Docker's 64 MiB default shared-memory
    # mount. This is a ceiling inside the selected profile cgroup, not eager allocation.
    shm_size: 2gb
    networks:
      - render
    restart: unless-stopped

networks:
  default: {}
  # Isolated network for app <-> render-service traffic. `internal: true` means
  # no route to the host or internet. Both services share it so the app can
  # reach the render service; the render container additionally blocks its own
  # egress (iptables, see the service's entrypoint) so the untrusted Chromium
  # can't use this shared network to reach the app.
  render:
    internal: true

volumes:
  openmaic-data:
  openmaic-postgres:

Technical Documentation: THU-MAIC/OpenMAIC

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (README.md)

🗞️ News

  • 2026-10-18 — v1.2.1: web research. Kimi, DeepSeek and Firecrawl join as web-search providers, and a new webFetch slot lets the agent read pages through a hosted reader (DeepSeek, Kimi or Firecrawl) instead of the built-in one; each is opt-in. Classroom chat can offer suggested replies, and retired models are removed from the built-in catalogs. Classroom chat now always runs on Pi and needs a classroom model with tool calling; read the release's Breaking Changes before upgrading.
  • 2026-10-16 — v1.2.0: server-first is stable. Generation runs on the server and survives closed tabs and restarts, models are configured once in openmaic.yml (defaults, locks, and an option to forbid user keys), materials are parsed as soon as they are attached, and the web app and the API share one pipeline. New: classroom export, as a PPTX that keeps every scene or as one offline HTML file that plays the lesson. It needs PostgreSQL and a long-running server; Vercel deployments stay on 1.1.x. Read Upgrading from 1.1.x before upgrading.
  • 2026-09-28 — v1.1.2: security — provider requests connect only to validated addresses and refuse redirects (GHSA-g87c-cm4q-cw5x).
  • 2026-09-27 — v1.1.1: security — hardened MinerU Cloud parsing (GHSA-cpjc-vgjh-c5jp).
  • 2026-09-24 — v1.1.0: agentic classroom chat (ask about any slide element, interactive, or whiteboard drawing); settings rebuilt around the course workflow; Token Plan connections.
  • 2026-09-15 — v1.0.3: security — access-code tokens expire, render-service network policy, pinned audio connections, Next.js RCE patch.
  • 2026-09-14 — v1.0.2: security — SSRF and DNS-rebinding fixes, classroom overwrite fix.
  • 2026-09-06 — v1.0.1: security and stability; tightens two defaults.
  • 2026-08-27 — v1.0.0: agent workbench, durable sessions, reusable skills, session materials, provider-neutral capabilities.

Earlier releases

  • 2026-08-14 — v0.3.2: video export hardening, server-backed persistence and asset registry, @openmaic/generation, four new locales, FunASR.
  • 2026-07-21 — v0.3.1: one-click MP4 export, direct slide editing, smarter "Edit with AI", expanded document parsing.
  • 2026-06-28 — v0.3.0: PBL v2, "Edit with AI" editor agent, @openmaic/* SDKs on npm, relicensed to MIT.
  • 2026-06-02 — v0.2.2: MAIC Editor Pro Mode, editable outlines, offline classroom export.
  • 2026-04-26 — v0.2.1: VoxCPM2 TTS with voice cloning, per-model thinking config, course completion page.
  • 2026-04-20 — v0.2.0: Deep Interactive Mode — 3D, simulations, games, mind maps, online programming.
  • 2026-04-14 — v0.1.1: language inference, ACCESS_CODE, classroom ZIP export/import, Ollama.
  • 2026-03-26 — v0.1.0: discussion TTS, immersive mode, keyboard shortcuts.

Full history in the changelog.

📖 Overview

OpenMAIC (Open Multi-Agent Interactive Classroom) is an open-source AI platform that turns any topic or document into a rich, interactive classroom experience. Powered by multi-agent orchestration, it generates slides, quizzes, interactive simulations, and project-based learning activities — all delivered by AI teachers and AI classmates who can speak, draw on a whiteboard, and engage in real-time discussions with you. The built-in OpenMAIC Skill works with OpenClaw as well as agent workbenches such as Codex, DeepSeek, and WorkBuddy, so you can generate classrooms from messaging apps like Feishu, Slack, or Telegram, or right inside your IDE.

https://github.com/user-attachments/assets/8f3f1e5f-1468-4e93-8054-afeeea683a61

Highlights

  • One-click lesson generation — Describe a topic or attach your materials; the AI builds a full lesson in minutes
  • Multi-agent classroom — AI teachers and peers lecture, discuss, and interact with you in real time
  • Rich scene types — Slides, quizzes, interactive HTML simulations, and project-based learning (PBL)
  • Whiteboard & TTS — Agents draw diagrams, write formulas, and explain out loud
  • Export anywhere — A .pptx that keeps every scene, one offline .html file that plays the whole lesson, or a classroom ZIP to move it to another deployment
  • Agent workbench integration — The OpenMAIC Skill supports OpenClaw, Codex, DeepSeek, WorkBuddy, and more — generate classrooms from Feishu, Slack, Telegram, 20+ messaging apps, or your IDE

🚀 Quick Start

Prerequisites

  • Node.js >= 22.19
  • pnpm >= 10
  • PostgreSQL 16 — courses are stored on the server. For local development
    pnpm db:up starts one in Docker for you.

1. Clone & Install

bash
git clone https://github.com/THU-MAIC/OpenMAIC.git
cd OpenMAIC
pnpm install

2. Configure

bash
cp .env.example .env.local            # API keys and server options
cp openmaic.example.yml openmaic.yml  # which models the server uses

The copied example needs only OPENAI_API_KEY in .env.local (or change its provider to one you have a key for); everything else in it is commented out until you want it. openmaic.yml declares providers (accounts the server can call) and gives each slot (a use of AI: the outline, slide content, text to speech, web search, …) a model. Keys stay in .env.local and are referenced as ${VAR}:

yaml
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}      # OPENAI_API_KEY=sk-... in .env.local
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}

slots:
  llm: openai:gpt-5.5              # the default chat model
  course.outline: anthropic:claude-sonnet-4-6
  video: null                      # turn a capability off

The slots you write are the server's defaults; users can change them, or connect services of their own, in the app's model settings unless you lock them or set allowUserKeys: false. The server validates the file at startup and names every mistake by its field (or, for broken YAML, its line).

To learn about Read
Slots, presets, fallbacks, lock, allowUserKeys, OPENMAIC_SECRET_KEY Configuration
Every provider preset and model ID Supported models
Upgrading: provider variables, server-providers.yml, DEFAULT_MODEL and MODEL_FALLBACK still work without openmaic.yml; MODEL_ROUTES must become slots Migrating from the legacy configuration
Which models users may pick: a provider whose key the server holds and that selects an upstream model offers only the models in its models list (*_MODELS), plus the slot, default and fallback models Model authorization

Providers: OpenAI, Azure OpenAI, Anthropic, Amazon Bedrock, Google Gemini, DeepSeek, Qwen, Kimi, MiniMax, Grok (xAI), OpenRouter, TokenDance, Doubao, Tencent Hunyuan/TokenHub, Xiaomi MiMo, GLM (Zhipu), Ollama, Lemonade and FunASR (local), and any OpenAI-compatible API.

[!TIP]
Recommended setup: OpenMAIC is at its best with every modality turned on — generated illustrations, narration, video clips, web search, and web fetch. A token plan configures the capabilities it supports with one key (see the examples below). Choose a fast long-context model such as deepseek-v4.1-flash as the default.

More examples: token plans, Xiaomi MiMo and GLM, Amazon Bedrock

MiniMax token plan quick example (one key for chat, image, video, TTS and web search; tokendance works the same way):

yaml
providers:
  minimax:
    preset: minimax
    apiKey: ${MINIMAX_API_KEY}

slots:
  llm: minimax:MiniMax-M3
  image: minimax                   # a provider id alone: the plan's default model
  video: minimax
  tts: minimax
  webSearch: minimax

TokenDance quick example with a fast long-context default model:

yaml
providers:
  tokendance:
    preset: tokendance
    apiKey: ${TOKENDANCE_API_KEY}

slots:
  llm: tokendance:deepseek-v4.1-flash
  image: tokendance
  video: tokendance
  tts: tokendance
  webSearch: tokendance

Xiaomi MiMo Token Plan and GLM (Zhipu) quick example:

yaml
providers:
  mimo:
    preset: xiaomi
    apiKey: ${MIMO_API_KEY}
    baseUrl: https://token-plan-cn.xiaomimimo.com/v1
  glm:
    preset: glm
    apiKey: ${GLM_API_KEY}
    baseUrl: https://open.bigmodel.cn/api/paas/v4   # or https://api.z.ai/api/paas/v4

slots:
  llm: mimo:mimo-v2.5-pro
  course.content: glm:glm-5.1

Use https://token-plan-sgp.xiaomimimo.com/v1 or https://token-plan-ams.xiaomimimo.com/v1 for the Singapore or Europe Token Plan clusters.

Amazon Bedrock quick example:

yaml
providers:
  bedrock:
    preset: bedrock
    models: [us.anthropic.claude-sonnet-5, us.anthropic.claude-opus-4-8]

slots:
  llm: bedrock:us.anthropic.claude-sonnet-5

Bedrock uses AWS environment credentials or the AWS SDK credential provider chain, with the region from BEDROCK_REGION (for example BEDROCK_REGION=us-east-1 in .env.local). For temporary credentials, set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN, or use an AWS profile / role available to the runtime.

3. Start the database

bash
pnpm db:up

Then uncomment the local DATABASE_URL line in .env.local:

env
DATABASE_URL=postgres://openmaic:[email protected]:5432/openmaic

pnpm db:up starts a development PostgreSQL on 127.0.0.1:5432 (set OPENMAIC_DB_PORT for another port). It is its own Compose project, openmaic-dev-db, shared by every checkout on this machine, with its own container and volume, so it never touches the database of a docker compose up stack. pnpm db:down stops it and keeps the data. Any other PostgreSQL works too.

4. Run

bash
pnpm dev

Open http://localhost:3000 and start learning! Without a DATABASE_URL the
server refuses to start and tells you how to provide one (see
Server-backed persistence).

5. Build for Production

bash
pnpm build && DATABASE_URL=postgres://... pnpm start

Keep the server running as a long-running process (a process manager or a
container): course generation runs inside it and continues after the browser
leaves the page. See the
Deployment guide for the required
configuration and for upgrading from 1.1.x.

Docker Deployment

bash
cp .env.example .env.local
# Edit .env.local with your API keys, then:
docker compose up --build

Open http://localhost:3000. The stack is two containers, the app and PostgreSQL. Courses, generated media and runtime sessions live in the named volumes openmaic-postgres and openmaic-data, which survive docker compose down and rebuilds; docker compose down -v deletes them. To configure models with openmaic.yml, create it from openmaic.example.yml and uncomment its mount in docker-compose.yml; otherwise connect a model service in the model settings once the app is running.

The Compose file is a personal installation: it runs in single-user mode (every browser sees one shared library) and listens on 127.0.0.1:3000 only. To serve other machines, protect it first:

  1. Set a long random ACCESS_CODE in .env.local (details). Without it, anyone who can reach the port owns, edits and can delete the whole library.

  2. Before the first start, set PERSISTENCE_POSTGRES_PASSWORD to a random value of letters and digits.

  3. Publish on the network address:

    bash
    OPENMAIC_PUBLISH_ADDRESS=0.0.0.0 docker compose up -d --build

OPENMAIC_PUBLISH_ADDRESS, OPENMAIC_PORT and PERSISTENCE_POSTGRES_PASSWORD come from your shell or a .env file next to docker-compose.yml, not from .env.local. Overriding the Compose defaults, rotating the database password and the full upgrade notes are in Hosting and identity.

[!IMPORTANT]
Upgrading a Compose deployment? PostgreSQL now always starts, the app is published on 127.0.0.1 only, and every visitor is the same single owner. If .env.local sets PERSISTENCE_SHARED_OWNER_ID, also set OWNER_SINGLE_USER=false, or the app refuses to start. Read Upgrading a Compose deployment first.

Slow-network / China build acceleration

Docker builds support two optional build arguments. Both are empty by default,
so the standard command above keeps using the upstream Alpine and npm
registries.

  • ALPINE_MIRROR is an Alpine mirror hostname without https://.
  • NPM_REGISTRY is a complete npm registry URL.

Use public mirror endpoints only. Do not embed usernames, passwords, or access
tokens in these build arguments because Docker may record them in image metadata
or build provenance.

With Docker Compose:

bash
ALPINE_MIRROR=mirrors.tuna.tsinghua.edu.cn \
NPM_REGISTRY=https://registry.npmmirror.com \
docker compose up --build

For a direct image build:

bash
docker build \
  --build-arg ALPINE_MIRROR=mirrors.tuna.tsinghua.edu.cn \
  --build-arg NPM_REGISTRY=https://registry.npmmirror.com \
  -t openmaic:local .

These arguments do not accelerate Docker Hub pulls, including the Dockerfile
frontend and the node:22-alpine base image. Configure a Docker daemon registry
mirror separately if those pulls are slow. The pnpm store cache is reused by the
same BuildKit builder across builds, subject to normal cache garbage collection;
the cache only improves performance and is not required for a correct build.

Vercel Deployment (up to 1.1.x)

Serverless deployment is supported up to OpenMAIC 1.1.x. From 1.2.0, course
generation runs on the server in a process that outlives requests, so OpenMAIC
needs a long-running Node.js process with PostgreSQL (the
Docker deployment or pnpm start); Vercel and other
serverless hosts are not supported, and the repository no longer ships a
vercel.json. To deploy 1.1.x on Vercel:

  1. Fork this repository on GitHub, unchecking Copy the main branch only.
  2. In the fork, set the default branch to release/1.1.x (Settings → General → Default branch).
  3. In Vercel, Add New → Project and import the fork. Vercel builds its default branch; configure at least one LLM provider key as described in that branch's .env.example.

Such a deployment can move to a long-running host later without losing data:
point the new host's DATABASE_URL at the database it used, if it used one,
and serve it at the same address, since browsers keep their data per site; the
courses visitors kept in their browsers are imported the first time each
browser opens the upgraded app. See
Upgrading from 1.1.x
for every kind of deployment.

Server-backed persistence (PostgreSQL)

OpenMAIC keeps courses on the server. Course documents, folders, chat history, learner sessions and generated media live in PostgreSQL (media optionally in S3), served by the app itself at /api/persistence. The browser keeps only what belongs to the device, such as settings, playback position and caches; Settings → Clear Local Cache never touches the server.

  • DATABASE_URL is required. Without it the server prints the fix and exits with code 1. Compose sets it for you; for development, pnpm db:up starts a database.
  • Courses an older build kept in the browser are imported once per browser, automatically, the first time it opens the upgraded app. The browser copy is left untouched.
  • Invalid settings stop the server at startup with a one-line [boot] reason instead of a server that answers every request with 500.

Who owns a course. Every request resolves to an owner, and each owner has its own library. Pick one identity mode:

Mode Set Library
Single user (Compose default) OWNER_SINGLE_USER=true One library for everyone who can reach the server; guard it with ACCESS_CODE or loopback
Shared team PERSISTENCE_SHARED_OWNER_ID= together with ACCESS_CODE One library for the team behind the access code
Anonymous (default without either) nothing One library per browser cookie; a second browser sees an empty one
Your own accounts owner auth methods registered in instrumentation.ts One library per signed-in account

Set the mode before the first start of 1.2.0: classrooms that 1.1.x saved as files are imported for the owner configured then. See Upgrading from 1.1.x for every kind of deployment.

Running OpenMAIC for other people, or embedding it in your own product? Hosting and identity covers:

Optional: ACCESS_CODE (Shared Deployments)

Protect a deployment with a site-level password in .env.local:

env
ACCESS_CODE=your-secret-code

Use a long random value, at least 16 characters from a random generator: it is the only secret guarding the deployment. Visitors then see a password prompt, and every API route is gated too. When it is unset (the default in .env.example), middleware.ts checks no credential and every route, the API included, is reachable: an unconfigured deployment is not gated, and there is no second enforcement point. The code is remembered for 7 days in a signed HTTP-only cookie; attempts are rate limited only behind a trusted proxy with TRUST_PROXY_HEADERS=true. See Configuration → ACCESS_CODE.

Optional: Agent workbench and runtime

The Pro workbench, a course-building surface entered from the home page, is off by default. Turn on its build-time entry point and the server runtime; it uses the same PostgreSQL as the rest of the app:

env
NEXT_PUBLIC_PRO_WORKBENCH_ENABLED=true
OPENMAIC_AGENT_RUNTIME_ENABLED=true
DATABASE_URL=postgres://openmaic:openmaic-dev@postgres:5432/openmaic

The agent runs on the agent slot, which follows the default chat model unless assigned and needs a model with tool calling:

yaml
slots:
  llm: openai:gpt-5.5
  agent:
    model: openai:gpt-5.5
    api: openai-completions        # or openai-responses

The agent slot must not set thinking.effort. Its routes, the DEFAULT_MODEL caveat and runner tuning are in Hosting and identity.

Optional: Lemonade (Local AI Provider)

OpenMAIC supports Lemonade as a local, OpenAI-compatible provider for LLMs, image generation, TTS, and ASR. No API key is required.

Run Lemonade locally, then point OpenMAIC to it:

yaml
providers:
  lemonade:
    preset: lemonade
    baseUrl: http://localhost:13305/v1
  lemonade-tts:
    preset: lemonade-tts
    baseUrl: http://localhost:13305/v1
  lemonade-asr:
    preset: lemonade-asr
    baseUrl: http://localhost:13305/v1
  lemonade-image:
    preset: lemonade-image
    baseUrl: http://localhost:13305/v1

slots:
  llm: lemonade:Gemma-4-26B-A4B-it-GGUF
  tts: lemonade-tts
  asr: lemonade-asr
  image: lemonade-image

With the legacy variables, the same is LEMONADE_BASE_URL, TTS_LEMONADE_BASE_URL, ASR_LEMONADE_BASE_URL and IMAGE_LEMONADE_BASE_URL.

Optional: FunASR (Local Speech Recognition)

OpenMAIC can transcribe locally through FunASR's OpenAI-compatible server. The built-in provider supports SenseVoiceSmall, Paraformer, and Fun-ASR-Nano and requires no API key.

bash
python -m pip install torch torchaudio
python -m pip install "funasr==1.4.0" fastapi uvicorn python-multipart
# Add vLLM for Fun-ASR-Nano on NVIDIA GPUs
python -m pip install vllm
funasr-server --device cuda --model fun-asr-nano

Point OpenMAIC at the server:

yaml
providers:
  funasr:
    preset: funasr-asr
    baseUrl: http://localhost:8000/v1

slots:
  asr: funasr

(Legacy variable: ASR_FUNASR_BASE_URL=http://localhost:8000/v1.)

Use funasr-server --device cpu --model sensevoice for a CPU-only setup. See the FunASR deployment guide for production options.

Optional: Local Audio and Video Extraction

OpenMAIC can extract timestamped transcripts and prepared video keyframes locally. Install the system ffmpeg package so both ffmpeg and ffprobe are executable on PATH, then assign the asr slot (for example FunASR, Lemonade, or OpenAI) as above. The application resolves the executables at extraction time; ffmpeg is not an npm dependency and is not required to start or use OpenMAIC.

If the executables are unavailable, the local extractor is skipped. A configured AliDocMind provider remains available as the cloud extraction path. When neither local ffmpeg extraction nor AliDocMind is available, audio/video materials are marked failed with an actionable setup message instead of hanging or completing with an empty transcript.

Optional: MP4 Video Export (Render Service)

The "Export Video" menu builds a self-contained Hyperframes project entirely in the browser. Turning that into an MP4 needs Chromium + FFmpeg on Node 22, so it runs in an isolated render-service container rather than the app.

It's opt-in. Start it with the video-export compose profile:

bash
docker compose --profile video-export up --build

The app auto-detects the service via RENDER_SERVICE_URL (preset in docker-compose.yml) and enables one-click MP4 rendering. Without the profile — or when RENDER_SERVICE_URL is unset — export degrades to downloading the project ZIP for local CLI rendering. See render-service/README.md for standalone setup and tuning (RENDER_MAX_CONCURRENCY, etc.). When upgrading, upgrade the render-service together with the app or before it: render jobs are bound to their owner, and a mix of old and new versions fails closed.

Optional: MinerU (Advanced Document Parsing)

MinerU provides enhanced parsing for complex tables, formulas, and OCR. You can use the MinerU official API or self-host your own instance.

Declare it in openmaic.yml and assign the document slot to it: mineru-cloud for the official API, or mineru with the baseUrl of your own instance.

yaml
providers:
  mineru:
    preset: mineru-cloud
    apiKey: ${PDF_MINERU_CLOUD_API_KEY}

slots:
  document: mineru

Without openmaic.yml, the legacy variables PDF_MINERU_CLOUD_API_KEY or PDF_MINERU_BASE_URL in .env.local still work.

Optional: VoxCPM2 (Self-Hosted TTS with Voice Cloning)

VoxCPM2 is an open-source TTS model from OpenBMB with voice cloning. OpenMAIC ships an adapter; run VoxCPM on your own hardware and OpenMAIC will talk to it.

1. Run a VoxCPM backend. Three deployment styles, all behind the same OpenMAIC adapter. You pick which one with options.backend in openmaic.yml (step 2).

Backend Endpoint When to use
vLLM-Omni /v1/audio/speech OpenAI-compatible speech endpoint, ideal for GPU servers
Python API /tts/upload Official VoxCPM Python runtime via FastAPI
Nano-vLLM /generate Lightweight Nano-vLLM FastAPI deployment

See the VoxCPM repo for backend setup.

2. Point OpenMAIC at it. VoxCPM2 runs on your own network, so the deployment configures it in openmaic.yml (no API key required); workspaces cannot add it in Settings → Model Services:

yaml
providers:
  voxcpm:
    preset: voxcpm-tts
    baseUrl: http://localhost:8000/v1
    options:
      backend: vllm-omni          # vllm-omni (default) | python-api | nano-vllm

slots:
  tts: voxcpm

Voice registration works only on the vllm-omni backend; python-api and nano-vllm send the voice prompt with each request.

Without openmaic.yml, the legacy TTS_VOXCPM_BASE_URL=http://localhost:8000/v1 sets the endpoint but cannot choose a backend.

3. Manage voices. With VoxCPM2 assigned to the tts slot, open Settings → Model Services → Text-to-Speech → VoxCPM2 (the voice manager appears only when tts resolves to a voxcpm-tts provider). Three voice modes:

  • Auto Voice (default): OpenMAIC generates a voice prompt from each agent's persona at synthesis time. No setup required.
  • Prompt voice: describe the voice in natural language, e.g. "warm female teacher voice, calm and encouraging, mid-pitch".
  • Clone voice: upload a short reference audio clip or record one in the browser. The clip is stored in this browser (IndexedDB) and sent to your VoxCPM backend on each synthesis.

✨ Features

Agent Workbench and Pro Mode (v1.0.0)

The workbench adds a conversational course-building agent to OpenMAIC.
Its durable sessions can be resumed after a worker restart, accept follow-up
instructions while running, and stream a replayable event history to the chat
surface.

Open it from the Pro control on the home page. The workspace combines a
transient, collapsible folders/conversations rail with a chat pane and a
classroom pane whose open courses stay in tabs. Workspace controls return to
classic mode, and either entry remains gated by the public workbench flag plus
the configured server runtime.

The agent works through explicit, validated tools rather than editing opaque
blobs:

Area Capabilities
Plan and organize Plan multi-lesson curricula; create courses and folders; rename and move courses
Build and edit Read/search the stage DSL; atomically patch one scene; generate, duplicate, insert, delete, and reorder pages; edit narration and deck structure
Use materials Upload files; extract documents, audio, and video; search extracted text; fetch trusted web URLs; reuse material media
Create media Generate images and videos through configured server providers; generate narration audio
Import and inspect Import .pptx slides with their layout preserved; render scene previews for visual inspection when available
Configure the classroom List available voices, set the agent roster, and clone/register a voice when a pluggable registration adapter is configured

Twenty-four built-in skills cover curriculum planning, deep research, interactive,
lecture, workshop, vocational, and other teaching styles, slide/stage craft,
PPTX import, editing, and style reuse. User-authored skills are stored per owner
and can be created, read, and patched through the same runtime.

The server-backed workbench also exposes owner-scoped folder routes and a
per-viewer stage metadata sidecar for ownership, publication, and
generation-complete state. A stage ID acts as the capability for reading a
non-deleted course, but stage mutations remain restricted to its owner. The
material upload contract stores supported source bytes before lease-fenced
document or media extraction records derived text and images; media extraction
can select AliDocMind or the optional local ffmpeg/ffprobe provider.

Under the hood, agent sessions are database-backed with leases, heartbeats,
crash resume, cancellation, and follow-up steering, and database-maintained
revision counters keep per-stage and per-scene freshness monotonic so the
workbench refetches only the scenes that changed. Server routes resolve LLM,
media, ASR/TTS, and search configuration provider-neutrally: credentials never
reach the browser, uniform <CAP>__ENABLED=false switches can force
off any served capability, startup validation warns about bad model
configuration, and unresolved model routes fail loudly instead of guessing a
vendor.

Pluggable Storage

OpenMAIC stores course documents, learner runtime records and assets on the
server, in PostgreSQL. The @openmaic/storage package defines swappable stores
for those primitives — PostgreSQL-backed documents, learner runtime, assets,
durable agent sessions, session materials, and user skills, plus browser
implementations for embedders — and HTTP clients connect the browser to the
embedded persistence endpoint, while the server asset layer can keep bytes in
PostgreSQL or S3. Device-scoped KV values (settings, playback position) stay in
the browser.

Deep Interactive Mode (New!)

Passive listening? ❌ Hands-on exploration! ✅

As Einstein said: "Play is the highest form of research."

While Standard Mode focuses on quickly generating classroom content, Deep Interactive Mode goes further — creating interactive, explorable, hands-on learning experiences. Students don't just watch knowledge; they adjust experiments, observe simulations, and actively explore how things work.

Five Types of Interactive UI

| 🌐 3D Visualization

Three-dimensional visual representations that make abstract structures more intuitive. | ⚙️ Simulation

Process simulations and experimental environments for observing dynamic changes and outcomes. |
| --- | --- |
| 🎮 Game

Knowledge-based mini-games that reinforce understanding and memory through interactive challenges. | 🧭 Mind Map

Structured knowledge organization to help learners build an overall conceptual framework. |
| 💻 Online Programming

In-browser coding and instant execution for learning by writing, testing, and iterating. | |

AI Teacher Guidance

The AI teacher can actively operate the UI to guide students — highlighting key areas, setting conditions, providing hints, and directing attention at the right moments.

Available on Any Device

All generated interactive UI is fully responsive — desktop, tablet, or mobile.

Desktop Mobile
iPad

Need a More Complete and Professional UI Generation Experience?

If you are looking for a version with richer functionality, stronger interactivity, and deeper optimization for high-quality educational UI production, please visit MAIC-UI.

Lesson Generation

Describe what you want to learn or attach reference materials. PDF, Word,
PowerPoint, spreadsheet, text, image, audio, and video inputs can enter the
material pipeline; configured extractors turn supported sources into content
for generation. OpenMAIC's classic two-stage pipeline handles the rest:

Stage What Happens
Outline AI analyzes your input and generates a structured lesson outline
Scenes Each outline item becomes a rich scene — slides, quizzes, interactive modules, or PBL activities

Classroom Components

| 🎓 Slides

AI teachers deliver lectures with voice narration, spotlight effects, and laser pointer animations — just like a real classroom. | 🧪 Quiz

Interactive quizzes (single / multiple choice, short answer) with real-time AI grading and feedback. |
| --- | --- |
| 🔬 Interactive Simulation

HTML-based interactive experiments for visual, hands-on learning — physics simulators, flowcharts, and more. | 🏗️ Project-Based Learning (PBL)

Choose a role and collaborate with AI agents on structured projects with milestones and deliverables. |

Multi-Agent Interaction

| - Classroom Discussion — Agents proactively initiate discussions; you can jump in anytime or get called on

  • Roundtable Debate — Multiple agents with different personas discuss a topic, with whiteboard illustrations
  • Q&A Mode — Ask questions freely; the AI teacher responds with slides, diagrams, or whiteboard drawings
  • Whiteboard — AI agents draw on a shared whiteboard in real time — solving equations step by step, sketching flowcharts, or illustrating concepts visually.

Agent Workbench Integration

| The OpenMAIC skill package (skills/openmaic/) uses the standard SKILL.md format and can be loaded by various agent workbenches — besides OpenClaw, this includes Codex, DeepSeek, WorkBuddy, and others. It is a guided SOP covering the live demo, local setup, classroom generation, and secondary development on top of the @openmaic/* SDK.

OpenClaw is a personal AI assistant that connects to the messaging platforms you already use (Feishu, Slack, Discord, Telegram, WhatsApp, etc.). With this integration, you can generate and view interactive classrooms directly from your chat app without ever touching a terminal.

Just tell your agent assistant what you want to learn — it handles everything else:

  • Hosted mode — Grab an access code from open.maic.chat, save it in your config, and generate classrooms instantly — no local setup required
  • Self-hosted mode — Clone, install dependencies, configure API keys, and start the server — the skill guides you through each step
  • Track progress — Poll the async generation job and send you the link when ready
  • Secondary development — Guide you through building on top of OpenMAIC: create your own app with the @openmaic/* SDK (see the extend docs inside the skill)

Every step asks for your confirmation first. No black-box automation.

| Available on ClawHub — Install with one command:

bash
clawhub install openmaic
Or, in other agent workbenches such as Codex, DeepSeek, or WorkBuddy, import the skills/openmaic/ folder from the repo (or its zipped archive) into the workbench to use it:

Configuration & details

Phase What the skill does
Clone Detect an existing checkout or ask before cloning/installing
Startup Choose between pnpm dev, pnpm build && pnpm start, or Docker
Provider Keys Recommend a provider path; you edit .env.local yourself
Generation Submit an async generation job and poll until it completes

Optional config in ~/.openclaw/openclaw.json:

jsonc
{
  "skills": {
    "entries": {
      "openmaic": {
        "config": {
          // Hosted mode: paste your access code from open.maic.chat
          "accessCode": "sk-xxx",
          // Self-hosted mode: local repo path and URL
          "repoDir": "/path/to/OpenMAIC",
          "url": "http://localhost:3000"
        }
      }
    }
  }
}

Export

Format Description
PowerPoint (.pptx) Editable slides with images, charts, and LaTeX formulas. Interactive, quiz, and PBL scenes can stay in place as placeholder slides with an Open online button and a QR code
Resource Pack The PPTX plus each interactive scene as a self-contained web page
Offline HTML One file that plays the classroom without a network connection: narration with captions, spotlight and laser, slide video, quizzes and interactive scenes; with or without narration
Classroom ZIP Full classroom export (course structure + media) for backup or sharing

See Exporting a classroom for the options, the ZIP that classrooms over 400 MB are saved as, and opening an exported file on a phone.

Importing a classroom ZIP stores its embedded audio, images, video, and posters in the server asset pool before saving the course, so other browsers resolve those assets without the importing browser's cache. A ZIP is also a way to move a course between deployments: export it from one and import it on the other.

Offline / intranet classrooms: When you export a classroom (.maic.zip) or a Resource Pack, OpenMAIC inlines the external assets referenced by interactive scenes (KaTeX, Three.js incl. three/addons, Tailwind CDN, Google Fonts, images) into the exported HTML as data: URIs. The exported course then plays fully offline after import into an air-gapped/intranet instance — no public CDN is contacted at playback time. Assets that can't be fetched at export time (e.g. CORS-restricted image hosts) are reported and left as URLs. Classrooms exported before this feature still reference CDNs and must be re-exported to gain offline support.

And More

  • Text-to-Speech — Multiple voice providers with customizable voices
  • Speech Recognition — Talk to your AI teacher using your microphone
  • Web Search — Agents search the web for up-to-date information during class
  • Provider controls — Server-side capability discovery, model resolution, force-off switches, and fail-loud routing keep deployments explicit
  • Course freshness — Database-triggered per-scene revision counters, freshness events, and targeted scene fetches keep workbench views synchronized
  • i18n — Interface supports 12 locales across 11 languages: Simplified Chinese, Traditional Chinese, English, Japanese, Korean, Russian, Arabic, Portuguese (Brazil), Spanish (Mexico), French, Vietnamese, and German
  • Dark Mode — Easy on the eyes for late-night study sessions

💡 Use Cases

> "Teach me Python from scratch in 30 min" > "How to play the board game Avalon"
> "Analyze the stock prices of Zhipu and MiniMax" > "Break down the latest DeepSeek paper"

🤝 Contributing

We welcome contributions from the community! Whether it's bug reports, feature ideas, or pull requests — every bit helps.

Project Structure

text
/* Detailed source-code truncated for AI context efficiency. */

Key Architecture

  • Generation Pipeline (@openmaic/generation) — Two-stage: outline generation → scene content generation
  • Agent Runtime (lib/server/agent-runtime/) — PostgreSQL-backed sessions with leased execution, resume/steer semantics, skills, materials, and validated course tools
  • Persistence Layer (@openmaic/storage) — Swappable document, runtime, KV, asset, agent-session, material, and user-skill stores
  • Multi-Agent Orchestration (lib/chat/pi/) — Server-side Pi director loop that hands each classroom turn to agents through call_agent
  • Playback Engine (lib/playback/) — State machine driving classroom playback and live interaction
  • Action Engine (lib/action/) — Executes 21 action types (speech, whiteboard draw/text/shape/chart, spotlight, laser …)
  • Storage Layer (@openmaic/storage) — Runtime/Document/asset storage abstraction with a Postgres reference implementation; its HTTP contracts let you plug in any external storage service

How to Contribute

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

💼 Partnerships

This project is licensed under the MIT License, so commercial use is permitted free of charge. For partnership or collaboration inquiries, please contact: [email protected]


📝 Citation

If you find OpenMAIC useful in your research, please consider citing:

bibtex
@Article{JCST-2509-16000,
  title = {From MOOC to MAIC: Reimagine Online Teaching and Learning through LLM-driven Agents},
  journal = {Journal of Computer Science and Technology},
  volume = {},
  number = {},
  pages = {},
  year = {2026},
  issn = {1000-9000(Print) /1860-4749(Online)},
  doi = {10.1007/s11390-025-6000-0},
  url = {https://jcst.ict.ac.cn/en/article/doi/10.1007/s11390-025-6000-0},
  author = {Ji-Fan Yu and Daniel Zhang-Li and Zhe-Yuan Zhang and Yu-Cheng Wang and Hao-Xuan Li and Joy Jia Yin Lim and Zhan-Xin Hao and Shang-Qing Tu and Lu Zhang and Xu-Sheng Dai and Jian-Xiao Jiang and Shen Yang and Fei Qin and Ze-Kun Li and Xin Cong and Bin Xu and Lei Hou and Man-Li Li and Juan-Zi Li and Hui-Qin Liu and Yu Zhang and Zhi-Yuan Liu and Mao-Song Sun}
}

⭐ Star History


📄 License

This project is licensed under the MIT License.

Third-Party Components

The repository bundles workspace packages that are not covered by the root MIT license and keep their own terms:

When redistributing the repository as a whole, the terms of each bundled package above apply to that package's files.


Packages Docs README (`packages/docs/README.md`)

This is a Next.js application generated with
Create Fumadocs.

Run development server:

bash
npm run dev
# or
pnpm dev
# or
yarn dev

The site is mounted under the /docs basePath, so open
http://localhost:3000/docs in your browser.

Build the static site (next build + static export, output in out/):

bash
pnpm build

Explore

In the project, you can see:

  • lib/locales.mjs: Single source of truth for locales — language list, default
    locale, per-locale search tokenizer, RTL set, labels, and the /docs basePath.
    Plain JS so the scripts/postexport.mjs build step can import it too.
  • lib/source.ts: Content source adapter, loader() wires the MDX content with i18n.
  • lib/layout.shared.tsx: Shared layout options (nav, GitHub link, Live Demo link).
Route Description
app/(en) English (default locale), served unprefixed at /docs/<slug>.
app/(intl)/[lang] Other locales, served at /docs/<lang>/<slug>.
app/static.json/route.ts Build-time Orama static search index (served at /docs/static.json).
scripts/postexport.mjs Post-build step: per-locale index redirects + English alias stubs.

Fumadocs MDX

A source.config.ts config file has been included, you can customise different options like frontmatter schema.

Read the Introduction for further details.

Known limitations

English supports only top-level docs pages

The default locale (en) is served unprefixed (/docs/getting-started). To keep
it unprefixed without the hydration conflict that a catch-all two-route-group
split causes, the English route app/(en)/[slug] is a single (non-catch-all)
segment. As a result, nested English pages would 404 — e.g.
content/docs/guides/intro.mdx resolves for every other locale (their routes are
catch-all under app/(intl)/[lang]/[...slug]) but not for English.

Only top-level English docs pages work today. Adding nested English docs requires
revisiting the (en) route group (and resolving the hydration conflict that the
current split avoids); this is deliberately deferred.

Learn More

To learn more about Next.js and Fumadocs, take a look at the following
resources:


Packages Docs Content Docs Getting Started (`packages/docs/content/docs/getting-started.mdx`)


title: Getting started

OpenMAIC is a Next.js application that runs on your own machine. You need to configure at least one LLM provider: cloud providers usually require an API key, while local Ollama or Lemonade does not.

Prerequisites

  • Node.js 22.19.0 or later
  • pnpm 10.28.0 (if needed, run npm install [email protected])
  • At least one LLM provider; cloud providers usually require an API key, while local Ollama or Lemonade does not

Install

Clone the repository and install dependencies:

bash
git clone https://github.com/THU-MAIC/OpenMAIC.git
cd OpenMAIC
pnpm install

Installation builds the workspace packages required by the project, so the first install may take some time.

Configure

Copy the example environment file and configure at least one LLM provider:

bash
cp .env.example .env.local

Then configure a model. The simplest file setup is a minimal openmaic.yml next to .env.local, with the key in .env.local:

bash title=".env.local"
OPENAI_API_KEY=sk-...
yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}

slots:
  llm: openai:gpt-5.5

openmaic.example.yml is this same starting point, with optional providers and slots commented out. Without the file, the legacy provider variables still work as long as DEFAULT_MODEL names the model:

bash title=".env.local"
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=anthropic:claude-sonnet-4-6

To skip both, start the app and enter a plan's key in Settings → Token Plan, or a service's key in Settings → Model Services. Without openmaic.yml, DEFAULT_MODEL or a service set up there, no model is configured and generation fails.

See Configuration for complete provider, image/video generation, TTS, ASR, document parsing, web search, access control, and feature flag configuration.

Run

Courses are stored in PostgreSQL, and the server refuses to start without DATABASE_URL. Start a local development database (a separate PostgreSQL in Docker on 127.0.0.1:5432, shared by every checkout on this machine; set OPENMAIC_DB_PORT for another port) and set DATABASE_URL in .env.local:

bash
pnpm db:up
bash title=".env.local"
DATABASE_URL=postgres://openmaic:[email protected]:5432/openmaic

Start the development server:

bash
pnpm dev

Open http://localhost:3000. You should see the OpenMAIC classroom generator. Enter a topic or upload learning material, then click Generate; the AI teachers will build a complete, multi-scene classroom. Supported material formats depend on the selected parser and typically include PDF, Office documents, Markdown/plain text, images, and some audio/video formats. Consult the parser's supported formats for the exact range.

Export a classroom

Once a classroom and its media have finished generating, the download button at the top right of the classroom opens the export menu:

  • Export PPTX: editable slides. With placeholder slides (recommended) keeps interactive, quiz and project-based learning scenes where they are in the lesson, each as a slide with an Open online button and a QR code that open the scene in the online classroom. Slides only leaves those scenes out.
  • Export Resource Pack: the same PPTX choice, plus each interactive scene as a self-contained web page.
  • Export as HTML: one self-contained file that plays the classroom without a network connection, with narration and captions, spotlight and laser, slide video, discussion cards and interactive scenes. Quiz, interactive and project scenes wait until the learner moves on. A project-based learning scene keeps only its briefing (goal and milestones); continuing the project needs the online classroom and a network connection. If the export reports assets it couldn't bundle, those parts of interactive scenes don't show in the offline HTML; they are still available in the online classroom. With narration and video plays like the classroom; Without narration makes a smaller file that paces the captions by reading time and leaves videos out.
  • Export Classroom ZIP: the course structure and media, to back the classroom up or import it on another OpenMAIC deployment.

A classroom that would exceed 400 MB as one HTML file is saved as a ZIP instead: extract the whole ZIP and open classroom.html, keeping the audio/, media/ and images/ folders next to it.

The exported HTML file needs a web browser with JavaScript. On a phone, open it in a browser such as Safari or Chrome: on iPhone it does not play inside WeChat or the Files preview.

Build for production

bash
pnpm build
DATABASE_URL=postgres://... pnpm start

Or deploy with Docker or on your own server—see the Deployment guide.

Next steps


Packages @Openmaic Storage Docs Asset Http Contract (`packages/@openmaic/storage/docs/asset-http-contract.md`)

Asset registry HTTP contract

This contract exposes the asset operations — put / identify / resolve / remove / replace — over HTTP. All paths below are relative to a deployment-defined base URL. Path segments are percent-encoded UTF-8 strings. Metadata travels as JSON; asset bytes travel as bytes, as a multipart part on writes and as the whole body on reads. Successful operations with no return value respond with 204 No Content.

The server backend for this contract ships as createAssetHttpHandler, with asset requests dispatched by createStorageHttpHandler. The conformance server this package provides remains test-only: it implements no authentication or authorization model beyond what the contract's response codes require, because deriving the principal from an authenticated session is the reference server's job.

The design this contract serves has three layers — an allocated asset id names a registry entry, and a registry entry names a content hash, and a content hash names bytes. Only the first layer is on the wire. The content hash is an internal deduplication key: it MUST NOT appear in any URL, response header, response body, error message, error details, or log line.

That prohibition is not satisfied by omitting the hash itself. Bytes are shared across principals and their storage row has no owner, so no response header, body value, or status may be derived from the blob row except the representation's byte length. Every other value on a byte response comes from the registry entry — its media type, its revision, its own creation time. The byte length is allowed because it is an inherent property of the representation and is the same value a GET obtains from the returned bytes; retaining it in the registry lets HEAD reproduce Content-Length without fetching those bytes. Last-Modified is the trap worth naming: sourced from the shared row's creation time, as a static-file idiom naturally would, it reports a timestamp earlier than the caller's own write and thereby proves another principal stored those bytes first. Byte responses MUST NOT carry Last-Modified, Age, or any other value read from the shared row; note that the no-store requirement below already makes Last-Modified pointless. Frameworks that generate ETag and Last-Modified by default MUST have both disabled on these routes.

The hash MUST be a collision-resistant digest of at least SHA-256 strength, untruncated. This is a security requirement rather than an implementation preference, and it is the unstated precondition of two rules below: a server writes bytes unconditionally, so under a colliding digest one principal's write silently substitutes the bytes every other principal's entries resolve to — and an object-storage byte layer names each object by that digest, so a collision there is a cross-principal overwrite at the storage layer as well.

An asset id is opaque and unconstrained

An asset id is a string this package allocated, but nothing in this contract may depend on its shape. Ids are compared, never parsed. There is no id validator that can reject an id on either side, and a server MUST NOT refuse one for its content. (The client does inspect an id for the three transport classes below, but only ever to answer "miss" locally — never to reject.)

The consequence is the one that matters: an id this registry never allocated is a miss, not an error. An id from another id space, an empty string, a string containing NUL, four kilobytes of padding, ../../etc/passwd — each is an ordinary lookup that finds nothing. This is not leniency. An id whose shape could be rejected would answer a question the caller is not entitled to ask, and a caller who can distinguish "malformed" from "not yours" has learned something about the id space. The browser backend documents this rule for the same reason, and the shared conformance suite pins each case.

A server therefore stores and looks up the decoded id as an opaque value — a bound query parameter, or a derived constrained storage id — never as a path component, a filename, or an unescaped fragment of a query. An id like a/../../b, arriving percent-encoded as a%2F..%2F..%2Fb, is one opaque lookup that traverses nothing.

Ids that cannot be a path segment

Validity is a property of the id; reachability is a property of the transport. An id sits mid-path here, before the trailing content segment, and three classes of id cannot be carried there:

  1. An unpaired UTF-16 surrogate has no percent-encoding; encodeURIComponent throws.
  2. A whole-id . or .. is normalized away by URL path parsing before the request is sent, so /assets/../content would address /content.
  3. The empty id collapses /assets//content, which intermediaries that merge slashes rewrite to /assets/content.

The third has no counterpart in the KVStore HTTP contract, whose key is a trailing segment and round-trips when empty. The danger in all three is identical and it is not that the request fails — it is that the request succeeds against something else.

The client MUST therefore resolve these three locally, before building any request. It does not raise an error: it answers as a miss — resolve returns null, remove succeeds as a no-op, and replace fails as it does for any id the registry does not hold, with a synthesized ASSET_NOT_FOUND since there is no response to reconstitute one from. This is where this contract deliberately diverges from KV, which refuses an unencodable key with a client-side KEY_NOT_ENCODABLE. Throwing would be wrong here: an unknown id is a miss by the rule above, the shared conformance suite pins the empty string as a miss on both resolve and remove, and a client that threw would both fail that suite and reintroduce the shape oracle the rule exists to prevent. The distinction to preserve is not loud-versus-quiet; it is that the id must never address something it does not name.

Note that ids which merely look structural need none of this. ../../etc/passwd percent-encodes to ..%2F..%2Fetc%2Fpasswd, one ordinary segment that reaches the server and misses there; it illustrates the id-domain rule, not the local-resolution one.

Transport hazards a deployment owns

Beyond those three, the path carries an id through intermediaries that may refuse or rewrite it, and the shared suite pins ids in each category. A conforming deployment MUST pass the id segment through unmodified, and MUST verify that it does:

  • A percent-encoded NUL (%00) is rejected outright by some servers and filters.
  • A percent-encoded slash (%2F) is rejected or normalized by servers that do not allow encoded slashes by default — which the bucket/path/to/object.png shape produces.
  • A very long id may exceed a request-target ceiling and be refused with 431 before routing. No length is invalid; a long id is simply not reachable past that bound.

Each of these turns a pinned miss into a thrown error, and none is predictable client-side, so none may be papered over by mapping an unclassifiable 4xx to a miss — that would break the rule that only a specific code becomes a miss. They are deployment configuration, and the shared suite's cases stand as the acceptance test for it. Real ids, being allocated by this package, encounter none of them.

Endpoints

Method Path Purpose Success
POST /assets Allocate a new id and store the submitted bytes under it. 201 with { "id": "ast_…" } and X-Asset-Revision
GET /assets/{id}/content Read the bytes stored under an id. 200 with the bytes
HEAD /assets/{id}/content Read identity headers without reading the byte layer. 200, no body
PUT /assets/{id}/content Replace the bytes stored under an existing id. 204 with X-Asset-Revision
DELETE /assets/{id} Remove the registry entry. 204 for any id the policy admits

The route table admits exactly one segment after assets, and it is the id. There is no principal segment and no digest segment to supply, so any other path shape is 404 ROUTE_NOT_FOUND — a routing outcome that requires inspecting nothing. A path that does match a table entry but with a method that entry does not list is 405 METHOD_NOT_ALLOWED, carrying an Allow header naming that route's methods; since route matching never consults the registry, neither outcome discloses anything about an id.

No route takes a query parameter. A server MUST reject any request whose target contains ? at all with 400 VALIDATION_FAILED — stated on the raw target rather than on a parsed query, so that a bare trailing ? cannot be read as absent by one implementation and present by another. That is total where an enumeration of forbidden parameter names would not be, and it costs nothing, because there is no parameter here to preserve.

There is deliberately no metadata read route. No backend offers one: the browser store's public surface is put / resolve / invalidate / release / replace / remove / close, and no metadata member is ever returned to a caller. (contentType is read back, but only by the store itself, to label the bytes it hands out.) A route no backend can satisfy is not a contract but a promise. Warm-cache revalidation is what HEAD is for.

HEAD performs an ownership-checked registry identity read and MUST NOT read or materialize the asset bytes. Its status and headers are identical to GET for the same registry state; only the response body is absent. The registry's recorded byte length supplies Content-Length, so reproducing the GET headers does not require a byte-layer read.

Read routes MUST be served with Cache-Control: private, no-store and Vary: Cookie, Authorization, and MUST NOT be cached by any intermediary; the client sends its reads with cache: 'no-store' for the same reason, since a UA cache serving a stale 200 would defeat revision revalidation invisibly. Note that this differs from a content-addressed design, where bytes named by their digest never change and are safe to cache forever: here replace mutates the bytes behind a stable id, so no HTTP cache may serve a response for an id without revalidating. The client's own snapshot cache is a different thing and is governed below.

Byte responses MUST NOT advertise Accept-Ranges and MUST ignore a Range header, always answering 200 with the complete representation. A 206 would carry a response model this contract does not define, on a route whose headers are load-bearing for revalidation.

Transport: bytes and metadata

POST /assets and PUT /assets/{id}/content take multipart/form-data, with the parts in this order:

  • meta — application/json, the metadata object. Required on POST. Optional on PUT, where its absence is meaningful; see below.
  • bytes — the asset bytes, carrying the asset's own Content-Type. The package client uses application/octet-stream when the source blob has no type, because multipart parsers otherwise supply their own default media type.

The multipart framing MUST be parsed by a standards-conforming multipart parser; a deployment MUST NOT hand-roll one. A bespoke grammar disagrees with the parsers intermediaries use, and every such disagreement is a way for a scanner and the application to see different parts.

Both parts MUST carry a filename. Without one, a conforming parser returns the part as a string, discards its headers, and text-decodes its payload, silently replacing invalid UTF-8. Each filename is fixed by the package client, never read by this package, and never derived from caller data. Preserving meta as a file allows the server to require its Content-Type to be application/json and to decode its bytes as UTF-8 with errors reported rather than replacement characters inserted.

Multipart rather than metadata alongside a raw body, because there is nowhere safe to put the metadata. It is an open-ended object, and real callers fill it with generated-narration text and image-generation prompts — unbounded caller-supplied content. In a query parameter that content would sit in the request target, where it hits the request-target ceiling and is written verbatim into every intermediary's access log. A custom header has the same size problem for the same reason. Multipart is a standards-defined framing rather than a bespoke one, which is the property that matters; maxMetaBytes must be enforced against the parsed metadata part before the JSON is parsed rather than after.

From this, one rule the other layers have no need for:

Metadata is unbounded caller-supplied content. It MUST NOT appear in any URL, response header, response body, log line, error message, or error details. The single exception is contentType, which determines a byte response's Content-Type as specified below. In particular a Content-Disposition filename MUST NOT be derived from metadata: it would put caller content in a header, and an unescaped one is a header-injection primitive. Use a fixed name or the id.

The client's request-header hook MUST NOT set Content-Type, and a client whose hook does MUST fail loud rather than choose a winner. Under multipart this is more serious than mislabelling: overwriting Content-Type destroys the boundary parameter, and every write from that deployment fails to parse with an error naming nothing an operator can act on.

Sizes

Three byte limits and one part-count limit, because they bound different things:

Limit Default Bounds Measured on
maxRequestBytes 33 MiB The whole request Raw octets off the wire, before parsing
maxAssetBytes 32 MiB The bytes part Decoded part content
maxMetaBytes 64 KiB The meta part Decoded part content
maxParts 8 Number of multipart parts Parsed frames

Only maxRequestBytes is enforced before multipart parsing. It bounds the raw body read from the wire and is therefore the only ceiling on parser work and request-derived memory. The platform's Response.formData() then materializes every part before maxParts, maxMetaBytes, and maxAssetBytes are checked on the parsed result; those three limits bound what the handler accepts, not what the multipart parser processes.

This trade is deliberate. A streaming multipart parser with its own limits would introduce a second grammar that could disagree with the standards-conforming platform parser, recreating the scanner/application parser differential this delegation exists to remove. Bounded memory is preserved by maxRequestBytes; maxParts, maxMetaBytes, and maxAssetBytes remain finer-grained admission rules for the parsed request. Separate asset and metadata limits are still necessary because one shared admission limit either caps assets at metadata scale or admits metadata at asset scale. Per-part header bounding belongs to the standards-conforming parser rather than to this contract.

The outer bound is measured differently from the inner two, and deliberately so: it exists to stop reading, so it cannot wait for a decode. A handler MUST assert at construction that maxRequestBytes exceeds maxAssetBytes + maxMetaBytes with room for multipart framing, or the outer bound silently masks the inner one — with the defaults above equal, an asset at exactly maxAssetBytes would always be rejected by the request bound, reporting the wrong limit.

None may be inferred from Content-Length, which is a claim by the sender. Counting bytes as they are read is necessary but not sufficient for the decoded limits — under a Content-Encoding the bytes read are a fraction of the bytes stored, and the expansion lands inside the transaction that holds the write. A server therefore MUST reject Content-Encoding on these requests with 400 VALIDATION_FAILED. Exceeding any size limit is 413 PAYLOAD_TOO_LARGE, raised before any bytes are stored.

Malformed and hostile bodies

A request whose Content-Type is not multipart/form-data is 415 UNSUPPORTED_MEDIA_TYPE. A failure by the multipart parser, including invalid boundary framing, is 400 VALIDATION_FAILED with a fixed package message. The expected entries are exactly meta and bytes on POST, and bytes with optional meta on PUT; any other entry name or any duplicate is 400 VALIDATION_FAILED. Duplicate entries in particular MUST be rejected rather than resolved by a first-wins or last-wins rule: a scanning intermediary and the application choosing differently is a parser differential, and the two would disagree about which metadata a request carried.

When both entries are present, meta MUST precede bytes.

Recording the media type

On POST, the recorded media type is meta.contentType when that member is present — including when it is the empty string — and the bytes part's own Content-Type only when it is absent. The distinction is ?? rather than ||, and it is observable: an explicitly empty contentType records the empty string and is therefore served as an attachment, where a fallback would have served the blob's type inline.

On PUT, when meta is present it replaces the entry's metadata wholesale and the media type is derived the same way. When meta is absent, the entry's existing metadata is retained and its media type is retained unless the bytes part carries one.

The package client always carries a media type on bytes: an untyped source blob is sent as application/octet-stream. This is necessary because a standards-conforming parser supplies a default media type for a file part whose header omits one, so omission cannot survive parsing as an empty type and cannot signal retention. Metadata omission still retains the existing metadata object; only the media type follows the replacement bytes.

The two branches are metadata present versus metadata absent, matching the browser backend's metadata replacement and retention behavior. The absent branch is the one that matters: regenerating bytes in place while keeping the recorded provenance is the use case replace exists for. A wire on which meta were mandatory would force a client to send {}, erasing the accumulated prompt, model, narration, and voice on every regeneration — through a call that returns 204.

Media-type retention is a deliberate divergence. The browser backend can retain the existing media type when metadata is absent and the replacement blob is untyped. The HTTP path cannot reproduce that behavior because a conforming multipart parser supplies a default type for a file part whose header omits one. The package client therefore sends application/octet-stream for an untyped replacement, and that type replaces the recorded media type even while the existing metadata object is retained.

Serving bytes

Every response carrying bytes MUST send X-Content-Type-Options: nosniff and MUST serve a Content-Type from a renderable allowlist. The default allowlist is:

text
image/png  image/jpeg  image/gif  image/webp  image/avif
audio/mpeg  audio/mp4  audio/ogg  audio/wav  audio/webm
video/mp4  video/webm  video/ogg

Matching is exact, case-insensitive, and whole-string, with no parameters accepted. A deployment may narrow this list; widening it is a decision about executable content. Excluded, and MUST remain excluded: image/svg+xml and image/svg, text/html, application/xhtml+xml, text/xml and application/xml, and application/pdf. These are document formats that execute script, not media, and serving one same-origin turns stored bytes into stored script.

A recorded media type that is not a string, is empty, or is outside the allowlist is served as application/octet-stream with Content-Disposition: attachment. The empty case is real when metadata explicitly carries an empty contentType. The client MUST label its minted object URL from the served Content-Type and from nothing else; labelling it from metadata would reintroduce inside a blob: URL exactly what the allowlist excludes.

Relabelling is not refusal. resolve returns a minted URL for every stored asset regardless of media type; a non-renderable asset yields a URL labelled application/octet-stream that a media element will not render, and that is the intended outcome rather than a miss. Returning null instead would fail the shared conformance suite, every blob in which is text/plain — outside any sensible allowlist — and would conflate "these bytes are not safe to render inline" with "there are no bytes." The disposition header governs direct navigation and does no work on this path, where bytes reach the caller through fetch; the protection that carries the weight is the relabelling together with nosniff.

The three-layer design improves on a content-addressed one here, and it is worth stating why. Where bytes are shared across principals and the recorded media type travels with the bytes, one principal's metadata can determine how another principal's response is labelled — a stored cross-principal typing attack. Here the media type lives on the registry entry, and every entry belongs to exactly one principal, so a principal can only mislabel their own bytes. The attack is unrepresentable rather than mitigated, given the collision-resistant digest required above and the rule that no response value derives from the shared row.

`X-Asset-Revision`

GET and HEAD on /assets/{id}/content, and the success responses of POST /assets and PUT /assets/{id}/content, MUST carry an X-Asset-Revision header: a monotonically increasing integer on the registry entry, starting at 1 and incremented by each replace. It is opaque to the client except for equality comparison.

It MUST NOT be derived from the content in any way. A content-derived validator — the reflex choice, and what an ETag from a static file server or object store would be — is the content hash under a different header name, and would disclose byte equality across ids to anyone holding two of them. Byte responses MUST NOT carry a content-derived ETag for the same reason.

Carrying it on the write responses too means a client learns the revision it produced without a second request, which would otherwise race a concurrent replace.

The headers and the body of a GET byte response MUST be produced from one transactionally coordinated read. Reading the revision and media type, releasing the transaction, and then reading the bytes lets a concurrent replace pair revision n headers with revision n+1 bytes, which the client then caches as authoritative. The browser backend closes the same window deliberately, reading the registry entry in the same transaction as the bytes. HEAD reads only the registry identity and recorded byte length in one ownership-checked query.

Resolution yields a client-minted object URL

resolve returns a URL the caller can use as an `` / <audio> / <video> source. Over this contract, the client fetches the bytes through its own authenticated request and mints a local object URL from them. The server exposes bytes; it does not hand out a URL for a media element to load.

This supersedes the earlier resolution that a server-backed deployment would resolve to a self-hosted proxy path by default. That shape does not survive contact with how a resolved URL is consumed. A media element sends only ambient credentials — it cannot carry a client's request-header hook — so a proxy path works for deployments whose sessions are cookie-based and silently fails for header-authenticated ones. Closing that gap requires the deployment to mint and verify short-lived URL tokens, which is a second credential form invented inside a storage library and offered as the default.

Fetching the bytes through the client's ordinary authenticated request path needs no second credential form. It also preserves the browser backend's semantics, which is what lets consumers written against that backend keep working — but only if the client observes the rules below, which are the substance of that claim rather than a consequence of it. And it removes a class of disclosure surface: the client never receives a URL into which a hash could be embedded, so content-derived validators, cache keys, and URL shapes stop being questions this contract has to get right.

The cost, stated plainly: there are no range requests and no progressive playback. The bytes are downloaded in full before a media element sees them, and a resolved asset is held in memory until released — on the server side too, since a byte read materializes the asset rather than streaming it. This is what the browser backend already does, so it is not a regression for any existing consumer, but it is a real ceiling on large media. Self-hosted proxy paths and signed object-storage URLs remain possible later shapes; they are additive, and adding one when a deployment needs it beats defaulting to a shape that works for only half of them. Signed object-storage URLs have since landed as exactly such an additive, opt-in shape; see Indirect byte egress below.

Client resolution semantics

These are normative. Two of them the shared conformance suite already pins; the rest it cannot, and saying which is which matters, because an implementer will otherwise trust a green suite to have checked them.

Pinned by the shared suite:

  • Concurrent resolve calls for one id coalesce onto a single request and a single minted URL. Minting twice orphans one of them — nothing can revoke it — and pins the asset in memory twice.
  • A remove invalidates the warm snapshot, and a resolve that misses retires it.

Normative but not pinned by the shared suite, and therefore the HTTP backend's own tests must pin them when it lands:

  • A returned URL is an immutable snapshot. A later mutation retires it rather than revoking it; already-issued URLs stay valid. Only release(id) and close() revoke. (The suite has no release coverage; the browser backend's own tests carry it.)
  • invalidate(id) is a local cache operation, distinct from release(id). It advances the id's resolution generation, prevents an older in-flight request from satisfying later callers, forgets the warm identity, and retires the current snapshot without revoking URLs already issued to holders. A replacement notification from another realm must invalidate before it resolves again.
  • The object-URL cache is keyed per asset id, never per content. A client that keys it on a digest of the response bytes discloses byte equality to a holder of two ids — the disclosure the whole design exists to prevent, reintroduced client-side. The suite asserts identical bytes yield distinct ids, never that they yield distinct URLs.
  • A replace invalidates the warm snapshot and any in-flight resolution for that id. (A put allocates a fresh id, which by construction has no cache entry to invalidate.)
  • The warm-hit identity is the revision together with the served media type — the HTTP analogue of the browser backend's content-hash-and-media-type pair. The suite is transport-agnostic and has no vocabulary for revisions.
  • A client records the revision carried by the response that delivered the bytes, never one learned from a HEAD. A HEAD decides only whether to re-fetch. Recording the HEAD's revision against bytes fetched afterwards labels revision n+1 bytes as revision n, and every later HEAD then confirms them as fresh.

One limit of this design is worth stating rather than leaving to be discovered: a replace performed elsewhere — another tab, another device — leaves a warm snapshot stale until the next revalidation, where the browser backend re-reads the registry inside every resolve. That is inherent to a network backend, and it is the one place these rules do not reproduce the browser's semantics.

Cross-origin deployments

The client reads non-safelisted response headers on both reads and writes. A cross-origin server MUST send Access-Control-Expose-Headers: X-Asset-Revision, X-Error-Code on GET and HEAD, error responses included, and MUST expose X-Asset-Revision on the 201 success response from POST and the 204 success response from PUT. Without the read revision, the client's cache either never validates or validates as always-fresh. Without the error code, a client cannot classify any HEAD error and falls back to a full byte GET on every miss — so the mechanism HEAD exists to provide stops working, silently, in exactly the deployment shape this section addresses. Without the write revision, the client reports a committed allocation or replacement as a malformed response; retrying the non-idempotent POST then allocates a second entry for the same bytes.

A client accepts a credentials option and passes it through untouched. A credentialed cross-origin deployment answers with a concrete origin and Access-Control-Allow-Credentials: true, never *.

Indirect byte egress (deployment opt-in)

Everything so far describes direct egress: a byte GET is answered 200 with the bytes materialized in the response body. A deployment MAY instead opt into indirect egress, under which a byte GET is answered with a short-lived signed URL for the object rather than the bytes — as a 302 Found with a Location for a generic consumer, or as a JSON descriptor for a client that asks for one, as specified below — when, and only when, the byte layer can sign. This is the signed object-storage shape anticipated above, and it is additive in the precise sense that a deployment which never opts in cannot tell it exists: off by default, and byte-for-byte unchanged when off. Only the byte GET changes shape; HEAD, writes, and remove are always answered directly, so X-Asset-Revision revalidation through HEAD works exactly as under direct egress.

Indirect egress is a preference, not a guarantee. A byte layer that cannot sign — the byte column in the transactional store cannot — answers 200 with the bytes even when the deployment opted in, and a client MUST therefore handle both shapes on the same route.

The rules that make the redirect shape safe are these, and an implementation MUST honor every one:

  • Authorization is unchanged and still per-read. The 302 is minted only after the same checks the direct read performs — the deployment policy hook and the per-principal ownership check, in the same coordinated read — and a miss is still 404 ASSET_NOT_FOUND, indistinguishable from the direct path. The redirect changes who serves the bytes, never who may ask.
  • A miss stays a miss wherever it surfaces. A registry miss is answered 404 ASSET_NOT_FOUND as it always was. Indirect egress adds one miss that arrives later: an entry owned and readable when the URL was minted, whose bytes are reclaimed before the client fetches it, which the object store answers 404. A conforming client MUST report that as ASSET_NOT_FOUND rather than as a malformed response — and only a 404 the store confirms as a miss: S3 and MinIO answer object errors with an XML body whose <Code> names the condition, so a client MUST treat exactly a declared NoSuchKey as the absent object and every other 404 (a wrong bucket, access point, or endpoint, under which the bytes still exist) as a malformed response. It is the same physical state the direct path reports as a miss, since the byte layer's own contract calls an absent object a miss and not an error, and what a read means must not depend on a deployment's egress setting. Signing MUST NOT probe for the object to close this window instead: the caller has already done the ownership-checked registry read, and an existence probe would both cost the round trip indirect egress exists to remove and make the mint's price vary with prior presence. This does put one requirement on the byte layer: an absent object MUST answer 404. S3 answers 404 NoSuchKey only when the signing identity holds s3:ListBucket on the bucket; without it a missing key answers 403, which no client can tell from a real credential fault, and the miss degrades to a malformed response. Grant it.
  • The signed URL is short-lived. Its signature is the credential, so possession of the URL serves the bytes to anyone for its whole lifetime: indirect egress converts a per-read authorized response into a bearer token with a fuse. The fuse is seconds, not minutes. It MUST also sit far below the byte reclamation grace period, so bytes referenced when the URL was minted cannot be collected while the URL is still usable. The packaged handler enforces that rather than documenting it: enabling indirect egress requires declaring the reclamation grace the deployment runs its collector with, and construction fails for any lifetime above a tenth of that grace, or above the handler's own fifteen-minute ceiling. The grace has no default here, deliberately. A default would be a guess about a component the handler does not own, and a guess is what turns this invariant into something a deployment can hold wrongly while every individual setting looks reasonable. The "an asset id is not a capability" rule survives precisely because the capability the redirect introduces expires.
  • The contract's response headers survive the redirect because they are pinned into the signature, not because the client re-derives them: the served media type after the renderable allowlist, the fixed attachment disposition for a non-renderable type, and the private, no-store cache posture. Object stores support exactly this as signed response-header overrides (response-content-type, response-content-disposition, response-cache-control); a byte layer that cannot pin them has no signing capability, whatever else it can sign. One header does not survive, and the design accounts for it: X-Content-Type-Options: nosniff is not among the overridable response headers, so the object store's answer lacks it. That is safe precisely because of the consumption model — the packaged client fetches the bytes and mints a typed blob: URL, so no media element or navigation ever sniffs the signed response's type; the allowlist relabeling has already decided what the bytes claim to be. A consumer that navigates to a signed URL directly gets no nosniff protection, which is one more reason the descriptor path is the one conforming clients use.
  • The 302 itself carries X-Asset-Revision, Cache-Control: private, no-store, and Vary: Cookie, Authorization, Accept — the read route's posture, since the redirect is as per-principal as the bytes it points at. A client whose fetch follows redirects opaquely observes only the final response's pinned headers, which is one reason the packaged client asks for the descriptor below rather than follow.
  • Cross-origin deployments own one more configuration. The byte fetch now crosses to the object store's origin, so that origin's CORS must admit the application origin and expose Content-Type. A deployment that skips this fails loud on the first read rather than silently.

What a conforming client does about the hidden 302 headers. Following a redirect is not neutral for it: the platform fetch forwards the original request's headers to the object store's origin — only Authorization is stripped across origins — so a deployment whose credential travels in a custom header would hand that credential to the object store, and the cross-origin preflight the forwarded custom headers provoke commonly fails there besides. A client therefore asks for the descriptor instead, and it asks through Accept: application/vnd.openmaic.asset-descriptor+json — a CORS-safelisted header, so the negotiation never turns a byte GET into a preflighted request, which a custom request header would. A redirect-egress server answers 200 with a JSON body {"url": ..., "revision": ...} under that same vendor media type as Content-Type, with the read route's cache posture and never a Location; the media type is what identifies the shape, so a stored asset can never parse as a descriptor — and to keep that true the type is reserved: a server MUST reject it in its renderable allowlist, since only the allowlist can put a recorded media type on the wire. The client then fetches the signed URL itself, with no deployment headers at all, takes the revision from the descriptor and the served media type from the byte response, and is otherwise unchanged: warm reads still revalidate through HEAD, which is never answered indirectly. A server that never redirects ignores the Accept and answers bytes exactly as before, so one client build serves both deployment shapes. The 302 remains the answer for generic HTTP consumers that did not ask for the descriptor; what they do with their own headers is their own affair.

The disclosure tradeoff, stated plainly. This contract forbids the content hash on the wire because it discloses byte equality across ids. The byte layers that ship key each object by that hash, so a Location into such a layer names the content hash, and a holder of two ids can compare the URLs they were redirected to and learn whether the bytes match — the disclosure the three-layer design exists to prevent, reintroduced at the egress point. Enabling indirect egress with a hash-keyed byte layer is therefore a deliberate deployment tradeoff, acceptable where byte equality across ids is not sensitive: public generated media is the motivating case, where payloads are large, visibility is already unrestricted, and moving the download off the application server is worth real cost. A deployment that needs the no-disclosure property MUST keep direct egress. A byte layer that keyed objects per registry entry rather than per hash would restore the property under indirect egress; none ships today.

The same tradeoff has a second, sharper edge that deployments should opt into with open eyes. An object store's GET response carries headers the signature cannot remove — ETag and Last-Modified among them — and the signed response-header overrides add but never strip. ETag is content-derived, so it sits in the same disclosure class as the hash in the URL. Last-Modified does not: because a deduplicated PUT rewrites the hash-keyed object, the header moves when another principal re-uploads the same bytes, and a holder of a signed URL who re-fetches it can observe shared-object write timing — who else wrote these bytes, and when. That is more than byte equality, and no shipped signer can suppress it. A deployment for whom that signal is sensitive MUST keep direct egress; a byte layer that never rewrote an existing object (or keyed objects per entry) would not expose it, and none ships today.

Allocation discloses nothing

Every successful POST /assets allocates a new id, including for bytes the registry already holds. Returning an existing id would let any caller test whether arbitrary bytes are already stored — an existence oracle over data the caller never stored, and the reason the three-layer design exists at all.

The requirement is stronger than the return value. Every branch taken and every value returned on the success path MUST be independent of whether the bytes were already present. A server MUST NOT read-then-write: it writes the blob unconditionally and inserts the registry entry, so both paths execute the same statements in the same order.

The pending/committed distinction is equally unobservable. A pending entry resolves, HEADs, replaces and removes exactly as a committed one does; no route reads it, no error mentions it, and no response varies with it. That follows from where it is written rather than from care taken on each route: allocation always writes the same columns, and commit is a side effect of a document write, so no asset request ever decides anything on it.

Whether an entry is still referenced is a different matter, and there is exactly one intentional branch on it: the quota bullet below, which accounts live logical bytes and therefore reads that state on POST and PUT. A caller near its quota can observe that a write it makes succeeds after a document write releases an earlier entry. That is the same channel the quota bullet already documents — a deployment-configured limit whose whole purpose is to vary with what the principal holds — and it is listed there rather than claimed away here.

Three channels stay outside the guarantee, and a deployment MUST budget for them:

  • Quota and metering. These MUST be accounted on the principal's logical bytes — the sum of the bytes referenced by that principal's live entries, live meaning every entry not yet marked unreferenced — and never on bytes physically newly written. Pending entries count whether or not their expiry has passed, because only the collector removes them; a quota that keeps counting an expired entry for one collection interval refuses a write slightly early, which is the harmless direction, where the opposite would admit one it should not. Counting entries that lost their last reference would make a quota a countdown rather than a ceiling: a regeneration would spend its predecessor's bytes forever instead of until the collector's grace period passes. Physical headroom above the sum of logical quotas remains the deployment's obligation, and the live-entry rule adds one term to it: an entry that has lost its last reference is outside the sum, so replacing its bytes cannot move the sum either, and a caller replacing such an entry repeatedly writes physical bytes a logical quota does not bound. The bound is the grace period — the entry is released after it, and the blob pass then reclaims every generation of bytes it named — so the headroom a deployment needs is its grace period times its peak replace rate. Refusing a replace on such an entry would be the tighter answer and is deliberately not taken: it would make an entry's lifecycle state observable on a write route, which the rest of this section exists to prevent. Under physical accounting a deduplicated write costs nothing while a fresh write costs quota, so two equally sized writes answer the oracle the rest of this design closes. The same applies to any storage estimate or billing readout. A deployment MUST also raise ASSET_QUOTA_EXCEEDED from the logical check before attempting a physical write, and MUST keep physical headroom above the sum of logical quotas — otherwise a write near capacity succeeds when the bytes deduplicate and fails when they are novel, and the filesystem answers the question the accounting rule closed.
  • Cost, on the write path only. A deduplicated write does less physical work than a novel one, so POST and PUT latency and write volume vary with whether the bytes were already present. This contract does not promise constant-cost writes, and a deployment requiring that must provide it above this layer. Note that remove is not in this category: because reclamation is offline, it deletes one row and costs the same whether or not another principal holds the same bytes — a channel that a synchronous reclaimer would have left open. The reference-count value itself is unobservable in any response.
  • Anything a deployment adds. A diagnostic, a storage estimate, or a rate-limit counter need not be read from the blob row to leak: it is enough that its value correlates with whether a write deduplicated. A deployment adding any such value to a response owes it the same analysis as the two channels above, over and beyond the blob-row rule, which closes only the values read directly from that row.

Where the bytes live

The registry — ids, owners, media types, metadata, and the reference count — lives in a transactional store. The bytes are a pluggable layer behind it: a column in that same store, or a filesystem, or an object store addressed by content hash.

That is possible because of a decision made in the next section: reclamation is offline, never part of a request. Were a request allowed to delete bytes, a concurrent write deduplicating onto those same bytes could commit against them, and a byte layer outside the transaction would have no way to serialize the two — which is what would force bytes into the transactional store. Offline reclamation removes that race from every request path, and what remains is coordination on a row rather than on the bytes.

The invariant a byte layer must satisfy is therefore an ordering rule, not an atomicity one:

  • The blob row is claimed first, in the transactional store, before the bytes are written. This is the step that serializes a write against the collector: claiming the row takes its lock, so a collector already holding it finishes first and the write then re-claims the row. Writing bytes before claiming would not be safe — the collector could delete them while the claim waited, leaving a fresh entry that points at nothing.
  • Bytes are then written, before the entry that references them. A crash between the two leaves bytes nobody references, which costs storage and loses nothing; the reverse order would leave a registry entry pointing at bytes that were never stored.
  • The write is unconditional. It must not be skipped because the bytes appear to be present already — partly because the interleaving above produces exactly that appearance, and partly because branching on prior presence is what the allocation rule forbids.
  • Bytes are deleted after the last row referencing them, and only by the offline collector.
  • The reference count and the decision to collect are serialized on that same blob row, which every byte layer keeps regardless of where the bytes themselves sit.

One consequence is worth stating rather than leaving to be discovered: byte-layer I/O happens while the registry transaction is open. The write path holds the blob row's write lock across the upload, and the read path holds a shared blob-row lock across the download so the collector cannot delete the object in flight. For a byte layer inside the registry store this costs nothing; for an object store it means a row lock and a database connection are held across network I/O, which is a consideration for how a deployment sizes maxAssetBytes and its connection pool. The read lock is shared, not exclusive, so concurrent reads of the same bytes do not serialize against one another.

Two implementations ship, and the interface exists because both are real rather than because one might be one day. A byte layer in the transactional store keeps bytes in a column: nothing to reconcile, since a rolled-back transaction leaves nothing behind, but its bytes flow through the write-ahead log and the backups, so backup and replication volume scales with stored assets — which is the reason to size maxAssetBytes deliberately, and eventually the reason to move off it. An object-storage byte layer keeps that volume out of the database at the cost of one housekeeping duty: the crash window above leaves unreferenced objects, so a deployment reconciles or expires them. Naming each object by its content hash keeps those orphans harmless — the next write of the same bytes overwrites one idempotently, so nothing has to hunt for them.

Reclamation is offline

Bytes are shared across principals; a registry entry is not. remove deletes the registry entry and nothing else: it never reads, counts, or deletes bytes. Bytes outlive their last reference until a separate, offline collector takes them — one that finds byte rows with no references older than a grace period, locks each, re-checks, and deletes.

Three things follow, and each is a reason the design is this way rather than a consequence to tolerate:

  1. Request paths never delete bytes. The write path claims the blob row before storing, and the read path holds a shared lock while reading bytes. The collector takes an exclusive lock before deletion, so it waits for in-flight reads and writes without serializing readers against one another.
  2. A side channel closes. Were remove to reclaim, its cost would vary with whether another principal still held the same bytes, so a caller could learn that by deleting their own asset and timing it. A remove that only deletes one row costs the same either way.
  3. Nothing is given up, because the guarantee never existed. With global deduplication, remove could never promise the bytes were destroyed — another principal referencing them keeps them. Synchronous deletion therefore bought far less than it appeared to.

The cost is real and belongs to the deployment: storage grows until the collector runs, and bytes a user deleted remain on disk until then. A deployment MUST configure the collector and its grace period; leaving both unset is a design that grows without bound. The retention posture that follows is the one deduplication already implied, now with a stated delay rather than an implied one.

The reference count spans all principals — that is what makes global deduplication reclaimable — and its value MUST NOT be observable in any response.

Collection is a host decision, not a contract event. A registry entry whose bytes are gone resolves to a miss rather than an error, because inventing an error would make collection observable.

Reference counting has a second level, from documents to asset ids, and a server implementing this contract MAY implement it. It is the same pattern one level up: an entry records when a document first named it and when the last document stopped naming it, and the same offline collector releases it after a grace period.

The second level has four parts:

  1. A reference table. A row per (stage, scope, scene, asset id) the stored documents hold, maintained inside the document store's own write transactions, at the granularity those writes already have — a full save replaces a stage's rows, a scene write replaces that scene's, a delete removes them. The scope distinguishes a stage-level slot (stage whiteboards, the video manifest — which no scene owns) from a scene's own slots, and it MUST be a field of its own rather than a reserved scene id: scene ids are opaque and unvalidated, so a scene whose id matched any sentinel would otherwise share a key with the stage-level rows, and whichever scope was written second would delete the other's rows and mark its assets unreferenced. A slot value becomes a row only if the registry holds an entry under that id, established by a join in the same transaction. Nothing parses an id: placeholders, data: payloads, legacy URLs and ids from other id spaces produce no row and no error, which is the same rule that makes an unknown id a miss on every read.
  2. Pending, then committed. A POST /assets allocates its entry with an expiry rather than a permanent claim, because the window between "the bytes were stored" and "a document names them" is bounded by nothing on the wire. The first document write that names the id commits the entry and retires the expiry, inside that write's transaction. There is no route for this and nothing on the wire changes: a client stores bytes, then writes the id into the document, exactly as before.
  3. An entry pass in the collector, ahead of the byte pass and under the same batch cap and the same lock-and-re-check-per-row discipline. It releases entries that are pending and past their expiry, and entries whose last reference left longer ago than the grace period. Releasing one does what remove does: delete the row, and stamp the blob when no entry names those bytes any more — so the byte pass then reclaims them after its own grace, as designed.
  4. A grace period rather than an instant. An entry that loses its last reference is stamped, not deleted. A deletion a user undoes, a document restored from an export, and the same id written back by a slower tab all land inside that window and simply clear the stamp again.

A deployment upgrading into this level has entries that predate it and documents with no rows. The collector walks the stored documents first, in bounded and resumable chunks, and may mark those entries committed only once that walk has covered every document — so an upgrade cannot release an entry the data it had not read still references. Each document is read inside the transaction that inserts its rows, and under a lock the document write paths take too, because reading outside it would let a concurrent save commit between the two and leave the walk inserting a reference that save had just removed.

The two halves are separately configured — a document store maintains the table, a collector releases entries — and a deployment that enables only the second deletes live media on the first expiry. A server MUST therefore record, in the registry's own storage, that a reference-maintaining document store has written, and its collector MUST refuse the entry level when that record is absent. An empty reference table cannot be told apart from documents that reference nothing; the absence of that record can. Refusing is not a warning: it is the difference between a misconfiguration that fails loudly on the first pass and one that is discovered when a user opens an old course.

Course deletion needs no separate path: deleting a document removes its rows and stamps the entries, and the next pass takes them after grace. DELETE /assets/{id} remains what it was, and a deployment that refuses it to its clients loses nothing by doing so.

Metadata domain

Metadata is a JSON object whose one contract-relevant member is contentType; every other member is caller-defined and stored verbatim.

Because it travels through JSON, this backend accepts only plain JSON values that survive serialization without changing meaning. It MUST fail loud, before sending, on values such as Map, Set, Date, non-finite numbers, negative zero, nested undefined, bigint, sparse arrays, symbol-keyed properties, non-enumerable properties, strings containing U+0000, class instances, and circular references. U+2028 and U+2029 are valid JSON string contents and MUST be accepted.

This is a real capability reduction, not a tightening of a lossy path, and a deployment moving from the browser backend to a server one must audit its metadata against it. The browser backend persists a structured clone, which carries Date, Map, Set, ArrayBuffer, bigint, -0, and cycles faithfully, and throws only for what it genuinely cannot represent. So these values do not degrade quietly today; over this transport a put that works locally starts failing. (Structured clone does drop symbol-keyed and non-enumerable properties, so those two are lossy on both sides.) U+0000 deserves its own mention in the other direction: it is legal in the browser and rejected by common server text and JSON column types, so without this rule it becomes a 500.

The list above binds the client, before sending, and every JSON-backed store, before writing. In particular, callers may use PgAssetStore directly without crossing the HTTP boundary, so its put and metadata-replacing replace branches MUST enforce the same lossless domain before any registry or byte write. The decision MUST be made by inspecting the value, never by trial-serializing it: a JSON.stringify pre-flight runs caller code — toJSON, getters — before validation has looked at anything, and a stateful accessor can show the probe one value and the serializer another. A native TypeError from serialization MUST NOT escape; it is replaced by a typed error, as a native SyntaxError is on the read path. That is not only about error typing: V8's circular-structure message enumerates property names from the object it failed on, which would put caller-supplied metadata into an error message in direct violation of the egress rule above. A validation error names the offending path and MUST NOT include the offending value; that error-egress rule binds direct store calls exactly as it binds HTTP calls.

A server receives the output of a JSON parse, so most of that list cannot reach it — non-finite numbers are a syntax error and the rest are unrepresentable in JSON text. Its obligation is correspondingly short, and is what the 400 row of the error table refers to: reject a meta part that is not a JSON object, and reject -0 and U+0000 inside any string. Those are the only members of the domain that survive parsing.

Nothing reads metadata back to a caller. It is persisted so that provenance recorded at generation time — what produced these bytes, from what prompt or narration, with what voice — survives alongside the asset for later manifest and export work. A deployment should know what it is holding: metadata accumulates caller-supplied text that no code path currently returns, and it is subject to whatever retention and privacy posture that text requires.

Principal derivation and authorization

The principal is derived server-side from the authenticated session and never appears in a path, query parameter, or body. This is the same non-negotiable that governs the other layers: a client-submitted principal is not proof of identity, and trusting one turns every route here into a lateral-authorization vulnerability. Every route requires authentication; there is no anonymous surface. A deployment's authenticate hook returning a principal in a malformed shape is 500 INTERNAL_ERROR rather than 400, because that is the deployment's bug and not the caller's.

A principal carries a required, opaque identity key, and that key is what every registry entry is partitioned on. This differs from the document layer, whose principal is all-optional because documents are author assets with no per-row ownership model; assets are partitioned, so the field the partition is keyed on cannot be optional. A principal that does not carry one has no asset capability at all and receives 403 FORBIDDEN_ASSETS on every route — never a shared partition. The distinction matters because an absent key is not a value: entries stored against one would collide into a single partition that every such principal could read, replace, and delete, which is a lateral-authorization hole produced by the type rather than by a bug.

Two of the three channels a principal or a hash could arrive through are closed structurally rather than by inspection: the route table admits no segment that could carry either, and no route takes a query string.

The third channel, headers, is closed by deriving no identity from it, and this contract deliberately does not attempt to police header names. A server MUST NOT take a principal, an owner, or a content hash from any request header except through the deployment's authenticate hook; no other header carries identity here, and one the implementation never consults for identity smuggles nothing. The rule is about what the implementation trusts, not a filter on traffic.

This is narrower than "reads no other header", and deliberately so: the transport headers below are read and acted on — Content-Type for the multipart boundary, Content-Length as an untrusted hint, and Content-Encoding rejected outright because it would defeat the decoded-size limits. Reading a header to decide how to frame or refuse a request is not the same as trusting one to say who is asking.

Rejecting suspicious header names instead would be worse than useless, and it is worth recording why so it is not reintroduced. A name-shaped denylist cannot distinguish a header that asserts identity from one whose name merely contains an identity word: User-Agent is caught by any pattern broad enough to catch X-Remote-User, and a server enforcing such a pattern answers 400 to every browser, SDK, and command-line client. More decisively, X-Forwarded-User, X-Remote-User, On-Behalf-Of, and their kin are exactly what an authenticating reverse proxy — auth_request, an OAuth2 proxy, an identity-aware gateway — injects for authenticate to read. A contract that rejects them forbids the deployment shape it most needs to support, and one that exempts them has rebuilt the enumeration it replaced.

A deployment that authenticates from front-door headers owns one obligation this contract states but cannot enforce: that front door MUST strip and reset those headers on every inbound request, so a client cannot supply its own. That is a property of the proxy, not of this handler.

Bodyless methods (GET, HEAD, DELETE) MUST reject any request body outright: a server that routes them without reading the body would let a prohibited field arrive unexamined. In the meta part of a write, a principal or contentHash member is rejected.

authorizeAssets is evaluated on the principal, the method, and the route before the entry is read, and MUST NOT receive the entry — otherwise a policy decision becomes entry-dependent and defeats the ordering rule below. It defaults to allowing any authenticated principal, matching document authorization rather than the deny-by-default of the administrative hooks. That default is not an absence of protection: the enforcing boundary is the principal recorded on every registry entry, checked on every route including every byte read. The hook is an additional policy layer for deployments that want one; the administrative hooks default to denial because they are privilege-escalation surfaces with no such per-row boundary behind them. A denial is 403 uniformly, on DELETE as on every other route — answering 204 to a caller whose policy forbids deletion would have them clear their reference and orphan the entry permanently.

Indistinguishability

An id belonging to another principal and an id that was never allocated MUST produce byte-identical responses — same status, same code, same message, same details, and the same headers — on every route in the table. Headers are named explicitly because this contract puts an outcome-varying value in one: a later diagnostic or Retry-After could break indistinguishability exactly as an added details member would. DELETE answers 204 for both, and for an already-deleted id. Ownership MUST be checked before any classification that could vary the response, including anything derived from the entry's revision or media type; otherwise the classification itself becomes the oracle. ASSET_NOT_FOUND responses carry a fixed message and no details, so that a diagnostic added later cannot break this.

An asset id is an identifier, not a capability. Authorizing once and treating the id as a bearer token afterwards is forbidden: every byte read re-checks ownership. A URL valid for one principal must not serve bytes to another that obtained it.

Errors

Every non-2xx response has this machine-readable JSON shape:

json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "@openmaic/storage: asset write body must carry a \"bytes\" part",
    "details": []
  }
}

details is optional. One class of response cannot carry this envelope: a 431, and anything else the framework emits before the handler runs, has neither a body of this shape nor an X-Error-Code. That is why the 431 row below names no code, and it is the reason the client rule is phrased as it is — a response the client cannot classify is never a miss.

Because HEAD responses have no body, every error response — on HEAD and, so that the two stay identical, on GET as well — MUST also carry the code in an X-Error-Code response header. Without it a client cannot tell ASSET_NOT_FOUND from ROUTE_NOT_FOUND from a gateway's own 404 on precisely the route it uses to decide whether a cached snapshot is still real. A HEAD response a client cannot classify is never a miss: it falls back to GET.

The client raises HttpAssetStoreError in every throwing case below.

Condition HTTP status Error code Client behavior
Malformed multipart, a missing or duplicate part, a query string, a prohibited header, a body on a bodyless method, or metadata outside the value domain (an id is opaque and never a validation failure) 400 VALIDATION_FAILED Throw with the server message
Request Content-Type is not multipart/form-data 415 UNSUPPORTED_MEDIA_TYPE Throw
Method not allowed on this route 405 METHOD_NOT_ALLOWED Throw
Metadata, bytes, or the whole request exceed the deployment's bound 413 PAYLOAD_TOO_LARGE Throw
The principal's logical bytes would exceed its quota 507 ASSET_QUOTA_EXCEEDED Throw
Request target exceeds the deployment's ceiling 431 (transport) A transport limit for a pathologically long id, not an id-domain rejection
No entry is stored under the id, or the entry is not this principal's 404 ASSET_NOT_FOUND resolve returns null; remove succeeds; replace throws
Route does not exist 404 ROUTE_NOT_FOUND Throw
Missing or invalid credential 401 UNAUTHENTICATED Throw
Principal may not perform the operation 403 FORBIDDEN_ASSETS Throw
Unexpected server failure 500 INTERNAL_ERROR Throw; the handler does not expose internal details

Only ASSET_NOT_FOUND becomes a miss, and the client MUST match the code together with the status. Status alone is not sufficient: ROUTE_NOT_FOUND shares its status and means a broken deployment, and a 401 or 403 MUST NEVER be reported as a miss. A gateway answering 401 while echoing an error code must not be read as "the asset was deleted" — that reading turns an auth outage into apparent data loss, and a caller that reacts by clearing references turns it into real data loss.

A response the client cannot interpret — a non-JSON body under a 2xx status, an allocation response without an id member — raises MALFORMED_RESPONSE, a client-side code with no server counterpart, carried by the same error type. The client never lets a native SyntaxError, TypeError, or URIError escape in its place.

The content hash MUST NOT appear in any message or details, on any path, including internal failures. 5xx responses collapse to INTERNAL_ERROR with a fixed message for that reason as well as the usual one.

Retry and atomicity guarantees

GET and HEAD are reads. DELETE is idempotent: removing an absent, already-removed, or foreign id succeeds. PUT /assets/{id}/content is idempotent in its effect on the bytes for the same body, though each application advances the revision — so a retry after an ambiguous failure leaves the bytes correct and the revision advanced twice, which is why the revision is opaque and compared only for equality.

POST /assets is not idempotent and is not implicitly retry-safe. Every call allocates, so a retried request that in fact succeeded leaves a second entry holding the same bytes. This is a direct consequence of server-assigned ids, and it is the property POST carries in the RuntimeStore HTTP contract. A caller that retries blindly should expect an orphan; a caller that cannot tolerate one must track the allocation it received.

A failed write leaves no registry entry, and no entry ever survives pointing at bytes that were not stored. It may leave bytes that nothing references — the crash window described under the byte layer, which costs storage rather than correctness. Whether it does depends on the layer: one inside the transactional store rolls back with the transaction and leaves nothing, while an object store cannot join that transaction and strands an object for reconciliation to take. There is no compare-and-swap. Concurrent replace calls on one id are last-writer-wins, and the revision reflects the order the server applied them. A resolve racing a replace observes either the pre- or the post-replace committed entry and never a mixture of the two.


Packages @Openmaic Storage Docs Document Http Contract (`packages/@openmaic/storage/docs/document-http-contract.md`)

DocumentStore HTTP contract

This contract exposes the complete DocumentStore interface over JSON HTTP. All paths are relative to a deployment-defined base URL. Path segments are percent-encoded UTF-8 strings and MUST NOT be exactly . or .., because URL parsers normalize dot segments before routing. Request and response bodies use application/json; successful writes return 204 No Content.

Endpoints

Method Path Purpose Success
PUT /documents/{stageId} Save the full MaicDocument; body stage.id must match the path. The store migrates stale input, stamps dslVersion, replaces stage/outline data, upserts incoming scenes, and removes omitted scenes atomically. 204
GET /documents/{stageId} Load and migrate one complete document. 200 with MaicDocument, or 404 DOCUMENT_NOT_FOUND
GET /documents List version-independent summaries. 200 with DocumentSummary[]
DELETE /documents/{stageId} Cascade-delete a document, its scenes, and its outline. Idempotent and intentionally not version-guarded. 204
PUT /documents/{stageId}/stage Validate and replace the stage row of an existing current-version document; body id must match the path. 204
PUT /documents/{stageId}/scenes/{sceneId} Validate and upsert a scene in an existing current-version document; body id and stageId must match the path. 204
GET /documents/{stageId}/scenes/{sceneId} Read one scene through the parent document's migrate-on-read semantics. 200 with the scene, or 404
DELETE /documents/{stageId}/scenes/{sceneId} Delete one scene. Idempotent for an absent scene or document, but version-guarded when the parent exists. 204

GET /documents returns only id, name, optional description, optional interactiveMode, optional taskEngineMode, createdAt, updatedAt, and sceneCount. Those fields are independent of the DSL version. Implementations MUST NOT migrate document content to produce this list and MUST tolerate an unknown or malformed stored version stamp.

Validation, versions, and JSON

Servers run their configured validateStage and validateScene gates before writes. A full save validates the migrated aggregate and rejects duplicate scene ids, mismatched scene partitions, and non-finite order values before changing storage. putStage and putScene repeat the same boundary validation and require an existing document whose stored dslVersion is exactly the server's current DSL_VERSION. A stale document must first be loaded and saved as a full aggregate; a future document must never be downgraded. deleteScene has the same current-version guard, while whole-document deletion deliberately does not.

HTTP implementations carry data through JSON and therefore accept only plain values that serialize without changing meaning. They fail before sending or storing values such as Map, Set, Date, non-finite numbers, negative zero, undefined, bigint, sparse arrays, class instances, symbol/non-enumerable properties, U+0000 strings, and circular references. The opaque outline is not DSL-validated or migrated, but it is still subject to this JSON transport rule. A store exposed by the server MUST contain JSON-safe document, scene, outline, and summary values; the handler validates read payloads before serialization and returns 500 NOT_JSON_SAFE with the offending path if this invariant is violated.

The handler streams request bodies into a bounded buffer. maxBodyBytes configures the bound on createDocumentHttpHandler and composed/reference server factories; it defaults to 32 MiB. A body that crosses the bound is rejected immediately with 413 PAYLOAD_TOO_LARGE.

Reads are migrated on both sides of the wire. The backing store performs its normal migrate-on-read, and the HTTP client migrates the returned document again so an older server cannot leak a stale envelope into a newer application. The opaque outline is removed before DSL migration and reattached unchanged.

Authentication and authorization

Documents are author assets, not learner-partitioned data. The contract does not prescribe a tenant or ownership model; deployments enforce their policy through the injected authenticate and authorizeDocuments hooks. The reference handler requires an authenticated principal for every document route. Its default authorization permits any authenticated principal; production deployments can supply authorizeDocuments to apply author, tenant, role, or document-level policy. Nothing on the wire names an owner, and the PostgreSQL backend keeps no ownership column on document_stages: a deployment that scopes documents per owner injects a PgDocumentStore bound to the request's owner with its own ownership relation (documentOwnership), so listings and writes are scoped by that relation while reads by id stay capability-by-id.

Errors

Every non-2xx response has a machine-readable JSON body:

json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "@openmaic/storage: invalid stage ...",
    "details": []
  }
}

details is optional. Messages retain backing-store semantics so the client exposes the same useful wording as BrowserDocumentStore.

Condition HTTP status Error code Client behavior
Malformed JSON, invalid stage/scene/document, body/path mismatch, non-JSON value, duplicate scene id, or stale incremental write 400 VALIDATION_FAILED Throw HttpDocumentStoreError with the server message
Request body exceeds maxBodyBytes 413 PAYLOAD_TOO_LARGE Throw HttpDocumentStoreError
Document does not exist 404 DOCUMENT_NOT_FOUND loadDocument returns null; required-parent writes throw with missing document semantics
Scene does not exist 404 SCENE_NOT_FOUND getScene returns null
Route does not exist 404 ROUTE_NOT_FOUND Throw HttpDocumentStoreError
The store refused the write as a matter of policy (DocumentWriteRefusedError, recognized by class or, for one from another copy of the package, by name and shape: isDocumentWriteRefusedError) 403 the refusal's own code, e.g. CREATE_REFUSED Throw HttpDocumentStoreError; nothing was written
Input or stored document was written at a future DSL version 409 FUTURE_VERSION Throw HttpDocumentStoreError with newer than this client's/version-guard semantics
A server-exposed read value is not JSON-safe 500 NOT_JSON_SAFE Throw HttpDocumentStoreError with the offending value path
Unexpected server failure 500 INTERNAL_ERROR Throw HttpDocumentStoreError; the reference handler does not expose internal details

Only the machine-readable not-found codes are translated to null. Status alone is not sufficient. Authentication failures use 401 UNAUTHENTICATED, and authorization failures use 403 FORBIDDEN_DOCUMENTS.

Retry and atomicity guarantees

Full saves are atomic: validation or any failed child write leaves the previous aggregate untouched. DELETE /documents/{stageId} and scene deletion are safely retryable. Stage and scene puts are idempotent for the same body, subject to the current-version guard. The contract does not add conditional requests or idempotency keys; deployments that retry an ambiguous full save must rely on the caller-owned stageId and replacement semantics.

Asset references, where a deployment maintains them

A server whose asset backend tracks document references maintains them as a side effect of these write routes, inside the same transactions: a save replaces the stage's reference rows, a stage or scene put replaces that scope's, and a delete removes them and marks the entries that lost their last reference. A host that retires documents by tombstone rather than by deletion — keeping the rows so the retired id stays retired — releases their assets through an in-process store method (withdrawAssetReferences) instead, because that retirement is the host's own concept and this contract has no route for it. Nothing about this contract changes — not a request shape, not a response shape, not a status code, not an error — and a document write is never refused, delayed, or altered by what it references. An id the asset registry does not hold simply records no reference, exactly as an unknown id is a miss on the asset routes. A store constructed with assetReferencePrincipals goes one step further: an id naming an entry held by a principal that function does not return for the store's owner records no reference and commits nothing either, so one owner's document cannot commit, expose or pin another owner's allocation. The mechanism, and the reclamation it enables, is specified in the AssetStore HTTP contract.


Packages @Openmaic Storage Docs Kv Http Contract (`packages/@openmaic/storage/docs/kv-http-contract.md`)

KVStore HTTP contract

This contract exposes the KVStore operations — get / set / remove / keys(prefix?) — over JSON HTTP for the account scope only. All paths below are relative to a deployment-defined base URL. Path segments and query parameter values are percent-encoded UTF-8 strings. Request and response bodies use application/json; successful operations with no return value respond with 204 No Content.

The conformance server in this package is test-only. It implements no authentication or authorization model beyond what the contract's response codes require; deriving the principal from an authenticated session is the reference server's job.

The scope is not on the wire

KVStore distinguishes two scopes. account is user data a server-backed deployment syncs across devices (provider and model configuration, profile); device is machine-local state (theme, locale, layout) that must never leave the machine. That is a property of the primitive, not a deployment setting.

This contract therefore has no scope at all: no scope path segment, no scope query parameter, no scope header, no scope body field. There is nothing to set to device, so there is no configuration or client bug that could ship a device value to a server — a request carrying a device value is not a request this contract can express. Servers MUST reject a request that invents a scope in any of those channels with 400 VALIDATION_FAILED rather than ignoring it, so a client attempting to describe a scope fails loud instead of silently having its intent discarded. "Any channel" is exhaustive, and each of the four is a place a real client or proxy might reach for a scope:

  • Path segment. The contract's segment after kv is entries or keys; a scope name there (/kv/device/keys, /kv/account/entries/{key}) is an attempt to route by scope and MUST be 400, not a 404 route miss. (A key named device is unaffected — that sits after entries, at /kv/entries/device.)
  • Query parameter. A ?scope=… MUST be rejected.
  • Header. There is no single canonical spelling, so the server MUST reject the whole prohibited set — at least scope, x-scope, kv-scope, and x-kv-scope. Checking only x-scope leaves the others open.
  • Body. get, remove, and keys are bodyless: a server that routes them without reading the body would let GET /kv/keys with {"scope":"device"} succeed, the guarantee failing silently. So a bodyless method (GET, DELETE) MUST reject any request body outright, and only set reads a body, where a scope field is rejected.

Every one of {get, set, remove, keys} × {path, query, header spellings, body} is closed, and the shared suite pins each cell.

The client half mirrors this in three layers, only the last of which actually carries the weight:

  1. HttpAccountKV is the only object in @openmaic/storage that can reach this contract, and its scope parameter admits 'account' alone — a literal 'device' at a call site is a type error. It also refuses one at runtime, because the type is not enough: TypeScript compares method parameters bivariantly, so this transport stands in for the wider KVStore wherever one is expected, and a caller holding it that way can pass any scope. Silently dropping that argument is how a device value reaches a server, so the transport takes the scope and fails loud on anything but account.
  2. HttpKVStore — the full KVStore — is the one place the two scopes are told apart. An unrecognized scope is refused rather than folded into the account path: guessing would send a value the caller believed was device-local to a server.
  3. HttpKVStore requires a LocalKVStore for the device scope — a store branded as keeping its values on this machine. KVStore alone is not enough, because a networked store satisfies it structurally: HttpAccountKV is a KVStore with one optional parameter fewer, so a KVStore-typed parameter would accept the very transport this design exists to exclude. The brand makes that a type error, and a runtime check refuses a remote store that a cast smuggled past the types.

There is no default and no fallback, so a deployment cannot end up with device values that have nowhere local to go.

The same pairing rule reaches the zustand adapter, which is where a store and a scope are chosen as separate arguments and therefore the easiest place to pair them wrongly: kvPersistStorage(store, 'device') requires a DeviceSafeKVStore at the type level and checks the brand at runtime. DeviceSafeKVStore is the capability that actually matters — "a device write stays on this machine" — and it is broader than LocalKVStore: it admits both a fully-local backend and the HttpKVStore composite, which routes device to its injected local backend while sending only account to a server. What it excludes is the pure HttpAccountKV transport, which has no local device backend and would put a device value on the wire; that store declares the capability false and is double-rejected.

Account settings are only as isolated as the deployment makes them

The account scope is where a server-backed deployment syncs a user's settings across devices, and the contract's principal derivation keeps one account from addressing another's keys over the wire. That is a routing guarantee, not a storage one. Before enabling settings sync, a deployment MUST provide real per-user isolation of the stored rows (the (principal, key) partition enforced end to end, not merely by a query filter a bug could drop) and encryption at rest for the values, which hold user configuration and profile data. The reference server is a reference: it demonstrates the principal derivation, not a production isolation or encryption posture, and MUST NOT be exposed with real user data as-is.

A key is a truly opaque, unconstrained string

A KV key is any string — of any length, containing any character, including NUL, separators, and things that look like structure. The KVStore primitive imposes no key-domain rules whatsoever: callers compose keys from unconstrained DSL identifiers (the DSL constrains ids only to be strings), and the browser primitive is a Map / Storage, which stores any string key. A rule in the key domain — a length cap, a / ban, a NUL ban — would be a transport concern reaching back into the primitive, and would make a key the browser can perfectly well store unreadable. There is no such rule, and no key validation. A server likewise MUST NOT reject a key for its content; it stores the decoded key as an opaque value — a bound parameter or a derived, constrained storage id — never as a path component or an unescaped fragment of a query, so a key like a/../../b (arriving percent-encoded as a%2F..%2F..%2Fb) is one opaque entry that traverses nothing.

Validity is a property of the key; reachability is a property of the transport. The two are kept separate, and this is the parity guarantee: a key is valid on every backend identically (all accept it), even where it is not reachable over a particular transport.

  • The browser backend uses no transport and imposes nothing — every key round-trips.
  • The HTTP backend carries a key as a URL path segment, so it inherits exactly three limits of that transport. Each is an HTTP-deployment concern, not a key-domain constraint, each makes the affected key fail loud rather than silently misbehave, and the browser backend stores every one of them fine:
    1. An unpaired UTF-16 surrogate has no percent-encoding — encodeURIComponent throws — so the client refuses it with KEY_NOT_ENCODABLE before building a request.
    2. A whole-key . or .. is normalized away by URL path parsing before the request is sent (/kv/entries/. collapses to the empty-key segment, /kv/entries/.. walks up a level), so sending it would silently alias a different entry. The client refuses these two whole keys with KEY_NOT_ENCODABLE as well. This is only the whole key: a key that merely contains a dot (a.b, prefix:id) is an ordinary segment and round-trips, and a ./.. prefix in a keys() query is unaffected (it is a query value, not a path segment).
    3. An extremely long key may exceed a deployment's request-target / header size ceiling (Node's default is ~16 KiB, above which the server answers 431 before routing). No length is invalid; a long key is simply not reachable past that transport bound.

None of these arise for the keys real callers produce (prefix:id from a DSL string). All are the URL transport's, not the key's; a deployment that must accept literally any key over HTTP would carry the key outside the URL path. This contract takes the simpler route and fails loud on the three cases above, and the browser backend is unaffected by all of them.

Endpoints

Method Path Purpose Success
GET /kv/entries/{key} Read one value. 200 with { "value": <json> }, or 404 KEY_NOT_FOUND
PUT /kv/entries/{key} Write one value from { "value": <json> }. Replaces any existing value. 204
DELETE /kv/entries/{key} Delete one value. Idempotent; deleting an absent key succeeds. 204
GET /kv/keys List the principal's keys. Optional ?prefix={prefix} returns only keys starting with it. 200 with string[]

Both read routes MUST be served with Cache-Control: no-store, and MUST NOT be cached by any intermediary. account is by definition the scope another device may have just written, and neither the client nor the server has a way to invalidate a cached copy when that happens — a cached read serves precisely the stale state this scope exists to move past. The client sends its reads with cache: 'no-store' for the same reason. Writes carry no such requirement, having nothing to read from a cache.

The value travels in an envelope rather than as the body itself. KVStore.get cannot distinguish a stored null from an absent key — both are null to a caller — but the wire still has to be unambiguous: a bare null body would be indistinguishable from an empty body or a stored null, leaving the framing to depend on Content-Length. The envelope makes "there is a value, and it is null" a statement the response can make. get maps 404 KEY_NOT_FOUND back to null, matching KVStore.get; the client MUST use the machine-readable code, not the status alone, so a 404 ROUTE_NOT_FOUND stays an error instead of masquerading as a missing key.

GET /kv/keys returns every matching key. The listing is not paginated, matching GET /documents in the DocumentStore HTTP contract; the primitive is sized for small configuration values, and a deployment that outgrows an unbounded listing needs a change to KVStore itself, not a second listing shape here.

The prefix is a literal, byte-for-byte comparison, not a pattern. A prefix is as opaque as a key: it too travels in the query string rather than as a path segment, so it may be empty (that is what "list everything" means), may be . or .. (legitimate prefixes of keys such as .hidden), and may contain any character a key may — %, _, \, /, and the rest. Only the transport-fatal characters and the DoS ceiling constrain it.

Because both the prefix and the keys it matches are opaque, a server MUST escape every metacharacter of its own query language before applying the comparison. In the obvious SQL translation, key LIKE prefix || '%', that means % and _, and the backslash — PostgreSQL's default LIKE escape character. Backslash is the one an implementer is most likely to skip, precisely because it is easy to assume a key could never contain one; it can — a key is opaque — and so can a prefix, and both reach the query. Escape all three, or the literal comparison silently becomes a pattern match. The result order is unspecified, but the listing MUST NOT repeat a key.

Value domain

Values travel through JSON, so this backend accepts only plain JSON values that survive serialization without changing meaning. It MUST fail loud before sending values such as Map, Set, Date, non-finite numbers, negative zero, nested undefined, bigint, sparse arrays, symbol-keyed properties, non-enumerable properties, strings containing U+0000, class instances, and circular references. U+2028 and U+2029 are valid JSON string contents and MUST be accepted. Keys are held to the same rule, because a key becomes a URL path segment.

This is intentionally narrower than BrowserKVStore, whose JSON.stringify would quietly rewrite a Date into a string or drop a nested undefined. Where the browser backend is silently lossy, the HTTP backend refuses.

Both backends agree on one deliberate exception: set(key, value) where value has no JSON representation at all (undefined, a function, a symbol) is a removal, not a write. Storing such a value would produce an entry that throws on read, so the key is deleted instead.

Every backend decides that by inspecting the value, never by trial-serializing it, and this is a rule about all of them rather than an HTTP detail. A JSON.stringify pre-flight runs caller code — toJSON, getters — before any validation has looked at anything, which reclassifies values as deletes ({ toJSON: () => undefined } stringifies to undefined) and lets a stateful accessor show the probe one value and the serializer another. Two backends probing and validating in different orders would disagree about whether a write was a delete, which is precisely the divergence the shared suite exists to prevent. Scope and key are validated first, then the delete case is recognized by type, and only then is anything serialized. A value that still fails to serialize is refused rather than quietly deleted.

Principal derivation

The principal is derived server-side from the authenticated session and never appears in a path, query parameter, or body. This is the same non-negotiable that governs learnerKey in the RuntimeStore HTTP contract: a client-submitted principal is not proof of identity, and trusting one turns every route here into a lateral-authorization vulnerability that lets one account read or overwrite another's configuration.

Every route requires an authenticated principal; there is no anonymous KV. Because keys are caller-chosen and this store holds user configuration, a deployment MUST scope every operation — including GET /kv/keys — to the derived principal, and MUST NOT expose a cross-principal listing on this contract.

Errors

Every non-2xx response has this machine-readable JSON shape:

json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "@openmaic/storage: kv write body must carry \"value\"",
    "details": []
  }
}

details is optional.

Condition HTTP status Error code Client behavior
Malformed JSON, a scope in any channel, a missing value member, or a non-JSON value (a key is opaque and never a validation failure) 400 VALIDATION_FAILED Throw HttpKVStoreError with the server message
A key cannot be percent-encoded for the URL transport (an unpaired surrogate) — a transport limit, not a rejection by the server — KEY_NOT_ENCODABLE Client-side only; throw HttpKVStoreError before any request. The browser backend stores the key
Request body exceeds the deployment's size bound 413 PAYLOAD_TOO_LARGE Throw HttpKVStoreError
Request target exceeds the deployment's size ceiling 431 (transport) An HTTP-transport limit for a pathologically long key, not a key-domain rejection
No entry is stored under the key 404 KEY_NOT_FOUND get returns null
Route does not exist 404 ROUTE_NOT_FOUND Throw HttpKVStoreError
Missing or invalid credential 401 UNAUTHENTICATED Throw HttpKVStoreError
Principal may not perform the operation 403 FORBIDDEN_KV Throw HttpKVStoreError
Unexpected server failure 500 INTERNAL_ERROR Throw HttpKVStoreError; the handler does not expose internal details

Only KEY_NOT_FOUND becomes null. Status alone is not sufficient — ROUTE_NOT_FOUND shares its status and means a broken deployment, and a 401 or 403 must never be reported as a missing key.

A response the client cannot interpret — a body that is not JSON despite a 2xx status, a get body without a value member, a keys body that is not an array of strings — raises MALFORMED_RESPONSE, a client-side code with no server counterpart. It is a typed storage error like any other: the client never lets a native SyntaxError escape in its place.

Retry and atomicity guarantees

Every operation is safely retryable. PUT is a whole-value replacement and therefore idempotent for the same body; DELETE is idempotent because deleting an absent key succeeds; both GET routes are reads. The contract offers no compare-and-swap: a concurrent write is last-writer-wins per key, which suits the small independent configuration values this primitive exists for. Callers needing an atomic read-modify-write should not model that state as KV.


Packages @Openmaic Storage Docs Reference Server (`packages/@openmaic/storage/docs/reference-server.md`)

RuntimeStore, DocumentStore, and AssetStore reference server

The @openmaic/storage/server subpath exports a Node-only HTTP request handler implementing the RuntimeStore HTTP contract, plus the DocumentStore HTTP contract and AssetStore HTTP contract when their stores are supplied. It accepts injected store implementations; the runnable @openmaic/storage/server/reference composition creates and initializes PgRuntimeStore, accepts optional host-created document and asset stores, and demonstrates the required node-postgres checkout/transaction/release pattern.

The OpenMAIC application also mounts these same composed handlers as an
app-integrated Next.js route at /api/persistence. That embedded route is the
deployment form used by the repository's default Docker Compose deployment;
it changes the Fetch/Node request boundary only, not either HTTP contract.

This module is a reference, not a production authentication service. The example bearer authentication is fully impersonatable. A production host must supply its own authenticated identity and authorization policy. It must also terminate TLS, bound request sizes and timeouts, rate-limit abusive clients, keep database credentials outside the process image, and expose the service only through an appropriate application gateway.

Deployment

Build the package and run the compiled entrypoint in a host that provides pg:

sh
DATABASE_URL=postgres://user:password@host/database PORT=3000 \
  node packages/@openmaic/storage/dist/server/reference.js

The executable main() binds to 127.0.0.1; createReferenceRuntimeServer() only creates and returns an unbound Node Server. The default bearer token payload is used directly as the demo learnerKey, self-merge is the only allowed merge, and admin operations are denied. Supplying documentStore adds the DocumentStore routes to that same server; any authenticated principal is allowed by default, or authorizeDocuments can enforce deployment policy. The factory also accepts authenticate, authorizeMerge, authorizeAdmin, and validator overrides. Replace the policy hooks before exposing a deployment:

  • authenticate(req) must validate a real credential and derive canonical learner and asset partition keys from server-controlled identity state.
  • authorizeDocuments(principal, req) must establish that the principal may access the requested author document operation. The default permits every authenticated principal.
  • authorizeAssets(principal, req) must establish that the principal may access the requested asset operation. The default permits every authenticated principal carrying an asset key; registry ownership remains mandatory on every operation.
  • authorizeMerge(principal, fromKey, toKey) must explicitly establish that the principal may migrate the complete source partition into the destination identity. Default denial is intentional.
  • authorizeAdmin(principal) must require a separately protected administrative role. Default denial is intentional.

The handler's payloadValidators option has the same whole-table replacement semantics as the BrowserRuntimeStore and PgRuntimeStore constructor option, and defaults to the DSL chat / quizAttempt skeleton table. Whatever you pass to the store, pass the same thing to the handler. createReferenceRuntimeServer() applies its payloadValidators override to both automatically. Its maxBodyBytes option applies to runtime and document routes and defaults to 32 MiB; oversized bodies receive 413 PAYLOAD_TOO_LARGE.

The package has no PostgreSQL driver runtime dependency. A host injects its Pool (or another compatible Queryable) and owns driver lifecycle. Every transactional operation must check out a fresh connection, issue BEGIN, run all callback queries on that same connection, issue COMMIT or ROLLBACK, and release it in finally.

Document endpoints

Every document route requires an authenticated principal. By default,
authorizeDocuments allows any authenticated principal; production deployments
must replace that policy when documents are tenant-, role-, or author-scoped.

Method Path Purpose
PUT /documents/{stageId} Save a complete document
GET /documents/{stageId} Load a complete document
GET /documents List document summaries
DELETE /documents/{stageId} Delete a document and its children
PUT /documents/{stageId}/stage Replace stage metadata
PUT /documents/{stageId}/scenes/{sceneId} Upsert one scene
GET /documents/{stageId}/scenes/{sceneId} Read one scene
DELETE /documents/{stageId}/scenes/{sceneId} Delete one scene

The reference composition applies its validateScene and validateStage
overrides to the HTTP handler; the host must configure the injected document
store with the same validators. Keep those validators in sync when composing
the lower-level handler yourself. Full response, validation, version, and retry
semantics are specified in the
DocumentStore HTTP contract.

Asset endpoints

Every asset route requires an authenticated principal with an asset partition key. Supplying assetStore adds these routes to the composed server; authorizeAssets can apply an additional deployment policy before any registry entry is read. Supplying byteEgress forwards the option unchanged to the composed asset handler: { mode: 'redirect', collectionGraceMs } opts into indirect byte egress for GET /assets/{id}/content, and omitting it keeps direct egress. The full tradeoff is specified under Indirect byte egress in the asset HTTP contract.

Method Path Purpose
POST /assets Allocate a new id and store bytes
GET /assets/{id}/content Read bytes and identity headers
HEAD /assets/{id}/content Read identity headers without bytes
PUT /assets/{id}/content Replace bytes behind an existing id
DELETE /assets/{id} Remove an entry; absent and foreign ids are no-ops

Writes use bounded multipart/form-data; reads are private and uncached. The full media-type allowlist, size limits, response headers, and client snapshot rules are specified in the AssetStore HTTP contract.

Asset reclamation a host must schedule

Nothing in this package runs on a timer. AssetCollector is a re-runnable pass a host has to schedule; left unscheduled, unreferenced entries and bytes grow without bound. A host composing this server owns that schedule — a periodic call to collect() in-process, a cron job against the same database, whatever fits the deployment. collect() is bounded per call and answers how many blobs it deleted; collectPass() answers that plus whether the batch was full, which is exactly "run again", and the entry counts below.

Three options decide how much of the two-level reclamation a deployment gets. All three are off or generous by default, so an existing deployment behaves as it did:

  • PgDocumentStore's trackAssetReferences (default false) makes the document store maintain the document_asset_refs table and the entry lifecycle columns inside the write transactions it already opens. It is off by default because those are the asset backend's tables: a deployment that provisions documents without ensureAssetSchema() has none of them. Enable it by constructing the store you inject as documentStore with the option set; nothing about the document routes changes either way. A host that retires a document by tombstone — keeping its rows, so deleting it is never an option — must call store.withdrawAssetReferences(stageId) in-process when it retires one, or that document's assets stay referenced forever; it is the release half of deleteDocument on its own, it leaves the document rows alone so re-saving re-references normally, and it throws rather than answering if the store does not track references. There is no ordering to observe against the one-time backfill: that walk reads stored JSON, which a retirement deliberately does not change, so the call records the retirement itself and the walk skips that document — a retirement made mid-upgrade is honoured like any other. That record is the only thing that makes the walk skip a live-looking document, deliberately: while the walk is behind, an entry's own state cannot say whether some document it has not reached still names it, so the walk re-references whatever it finds and lets the entry pass, which skips referenced entries, sort it out.

  • PgAssetStore's pendingTtlMs (default one day) is how long an allocated entry stays pending before it expires. The window it has to cover is "the bytes were stored, and then the document naming them was saved", which nothing on the wire leases, so a generous default is deliberate: unreclaimed bytes cost storage, while an expiry that fires before the document write costs a document its media. It is written on every allocation and only ever acted on by the entry pass below.

  • PgDocumentStore's assetReferencePrincipals (default unset) scopes that maintenance to the entries of the principals it returns: a write references and commits only those, and an id naming any other principal's entry records nothing, like an unknown id. It is a function of the store's bound owner, evaluated per write, so a store re-bound with forOwner scopes to the new owner. A host that partitions its asset registry per owner returns that owner's principal (plus any partition every owner may use), and gives AssetCollector the same function so the one-time backfill applies the same scoping per document owner — together with the same documentOwnership relation, which is where the collector reads each document's owner (document_stages records none). The collector refuses the function without that option; pass false when documents have no owners. Without it, any document naming an id commits and pins that entry, whoever holds it.

  • AssetCollector's documentReferences (default false) adds the entry pass, run ahead of the byte pass under the same batch cap and the same lock-and-re-check discipline. With it, the collector also backfills the reference table for a deployment upgrading into this level, in bounded chunks (referenceBackfillBatchSize, default fifty documents), and releases nothing until that walk has covered every document. Every transaction it opens carries a lock-wait budget, as the registry's writes do, so one stuck holder cannot park a scheduled pass indefinitely. A failure at this level — a lock timeout, a document the walk cannot read — is reported after the byte pass rather than instead of it, so a contended entry level never costs a deployment its byte reclamation for that interval.

Schedule the upgrade window when the deployment is quiet. The pass that finishes the backfill walk also runs the one-time legacy mark, which touches every entry written before the lifecycle columns existed: it locks them in batches of batchSize, marks each batch committed, and stamps the ones no document turned out to name. Each batch is one short transaction and the work is monotonic — what a batch marked stays marked, and a batch that fails is simply redone on a later pass — so nothing is lost either way. But those rows are the same rows a document save locks, so while the mark runs, a save touching one of them waits, and a save that waits past the collector's lock-wait budget fails with a retryable StorageLockUnavailableError. On a busy deployment run the first passes out of hours; a client that retries such a failure sees nothing else.

Waiting is all it does: every writer of asset_entries in this package — a document save, the collector's backfill, the collector's mark — takes all of one transaction's entry row locks in a single ascending statement before it writes anything, so two of them queue rather than deadlock. That property depends on the rule, not on luck: a writer added later that locks several entry rows in more than one statement, or in another order, puts the deadlock back.

Every pass also sweeps one bounded batch of live entries. Once the backfill walk is done, each pass locks the next batchSize committed, unstamped entries — an ascending batch, paged by a cursor that wraps at the end of the table — and stamps any of them no document references. It exists for the rows no write will ever stamp: a reference row that went away without the document write that would have stamped its entry — rows removed out of band, or a restore that reinstated documents but not the reference table. Such an entry is committed, unstamped and referenced by nothing, and without the sweep nothing would look at it again — it, its bytes and its share of the principal's quota would be held forever. The sweep is not a repair for mixed tracking states. A deleteDocument issued by a store with trackAssetReferences off deletes the document rows and nothing else, and no foreign key ties document_asset_refs to them, so that document's reference rows survive it: the entry stays referenced, by rows naming a stage that no longer exists, and neither the sweep nor anything else in this package reclaims those rows. That is one more reason to roll the two halves back together, below. The cost is one short transaction per pass holding one bounded batch of live rows, so a concurrent save touching one of those rows queues behind it; an entry that becomes an orphan is reached within ceil(entries / batchSize) passes.

documentReferences requires trackAssetReferences, and the collector enforces it. They are two halves of one mechanism: the document store is what commits an entry and what records the references the pass reads. A collector running the entry pass against a document store that maintains nothing would see every entry as pending and release each one when its TTL expires, while the documents naming them are still there.

That is not left to the operator to get right. A document store with trackAssetReferences records a marker row in the registry's own schema on every write that maintains references, and the collector reads it before its entry pass: absent, the entry level refuses with AssetReferenceTrackingNotEnabledError and nothing is released. Because that marker arrives with the first such write, a host that can vouch for every writer on the database should call store.declareAssetReferenceTracking() once at startup, after ensuring the schemas — otherwise a freshly installed or freshly switched-on deployment refuses on every interval until somebody happens to save a document, the backfill never starts, and the refusal looks like a broken pairing rather than an idle one. The blob pass still runs — it is correct with or without a reference writer — so a deployment that trips this keeps reclaiming bytes while it fixes the configuration. The check is a marker rather than "are there any reference rows", because an empty table is indistinguishable from documents that reference nothing.

The check catches enabling the halves in the wrong order; it does not catch rolling one back. The marker is never removed, so a deployment that has run with both and then sets trackAssetReferences back to false — a revert, a flag flip, a replica deployed from an older configuration — keeps passing the check while its new allocations are never committed and are released on their TTL. The same mixture has a quieter second symptom: a store with tracking off cannot touch the asset backend's tables at all, so a delete it issues leaves behind the withdrawal record of a retired document, and the collector's one-time walk will then skip whatever later claims that id. Roll the two back together, and prefer deriving both from one setting so they cannot drift. A heartbeat would close this, and is deliberately not used: it would make an idle deployment refuse to reclaim its own expired pending entries for the sole reason that nobody wrote a document that week.

One grace period (graceMs, default one hour) governs both levels: an entry and its bytes are two rows describing one asset, and separate windows would only let them disagree about how long an undo has. A deployment using indirect byte egress must keep its signed-URL lifetime far below that grace; assertSignedUrlTtlWithinGrace states the rule, and the asset handler applies it already.

Endpoint authorization matrix

The matrix treats learner, merge, and admin credentials as separate capabilities. An admin-only or merge-only credential does not implicitly own a learner partition; a deployment may combine capabilities, but every applicable check still has to pass.

Method and path No credential Owning learner Other learner Merge-authorized Admin-authorized
POST /runtime/sessions Deny (401) Allow Deny (403) Deny Deny
GET /runtime/sessions/{sessionId} Deny (401) Allow Not found (404) Deny (403) Deny (403)
PATCH /runtime/sessions/{sessionId}/status Deny (401) Allow Not found (404) Deny (403) Deny (403)
DELETE /runtime/sessions/{sessionId} Deny (401) Allow Not found (404) Deny (403) Deny (403)
GET /runtime/stages/{stageId}/learners/{learnerKey}/sessions Deny (401) Allow Deny (403) Deny Deny
POST /runtime/sessions/{sessionId}/records Deny (401) Allow Not found (404) Deny (403) Deny (403)
GET /runtime/sessions/{sessionId}/records Deny (401) Allow Not found (404) Deny (403) Deny (403)
POST /runtime/learners/merge Deny (401) Deny by default Deny by default Allow Deny
DELETE /runtime/stages/{stageId}/learners/{learnerKey} Deny (401) Allow Deny (403) Deny Deny
DELETE /runtime/stages/{stageId} Deny (401) Deny (403) Deny (403) Deny (403) Allow
DELETE /runtime Deny (401) Deny (403) Deny (403) Deny (403) Allow
POST /assets Deny (401) Allow in own partition Allow in own partition Deny without asset key Deny without asset key
GET /assets/{id}/content Deny (401) Allow for own id Not found (404) Deny without asset key Deny without asset key
HEAD /assets/{id}/content Deny (401) Allow for own id Not found (404) Deny without asset key Deny without asset key
PUT /assets/{id}/content Deny (401) Allow for own id Not found (404) Deny without asset key Deny without asset key
DELETE /assets/{id} Deny (401) Allow Allow as no-op Deny without asset key Deny without asset key

Threat model

learnerKey is an opaque partition key, never a credential. An attacker can alter path segments and JSON bodies, so trusting a submitted key enables lateral movement: reading another learner's sessions or records, writing records into their sessions, changing status, or deleting their data. The handler authenticates every contract operation and compares stored or submitted learner ownership before touching learner-scoped data. Direct stage / learner partition mismatches return 403 FORBIDDEN_LEARNER; missing credentials return 401 UNAUTHENTICATED.

Session-scoped routes deliberately conceal whether another learner's session ID exists. A credential with a different learnerKey receives the same 404 SESSION_NOT_FOUND as an absent session, and ownership is checked before future-version classification so version metadata cannot disclose existence. A principal with no learnerKey is different: it lacks the learner capability entirely and receives 403 FORBIDDEN_LEARNER on every learner-scoped route.

An asset id is an identifier, not a bearer token. Every asset operation rechecks the authenticated principal's ownership, including every byte read. An id belonging to another principal and an id that was never allocated are indistinguishable on every route: reads and replacements return the same fixed 404 ASSET_NOT_FOUND, while deletion succeeds as the same no-op.

Merge is a privilege-escalation boundary because it rewrites every source session across every stage. Merely owning either key is insufficient in a real identity system: the authorization hook must verify the account-linking or identity-upgrade proof for both the source and destination. The default is deny.

Stage cascade deletion and whole-runtime deletion are admin-plane capabilities. If exposed to ordinary learners they can erase every partition on one stage or across the entire runtime store, so both routes are controlled by the separate admin authorization hook and denied by default. Production systems should isolate admin credentials, audit decisions, protect against confused-deputy use, and avoid deriving admin authority from a learner-controlled claim.

Authentication failures and authorization denials should be logged without recording bearer credentials or sensitive request payloads. Operators should monitor repeated cross-learner denials, merge attempts, and admin-plane calls as possible account-enumeration or privilege-escalation signals.

Concurrency semantics during merge-time deletion

mergeLearner and deleteSession may race. If deletion verifies ownership and a merge changes ownership immediately afterward, the resulting deletion is equivalent to the legal serial order in which delete happens before merge. The handler therefore re-reads and re-checks ownership immediately adjacent to the delete call to shrink the race window; this is treated as a linearizable ordering case, not as an API or schema defect. A store-level conditional delete such as delete-if-owner could further harden this boundary in the future.

Version and concurrent-write classification

Reads return future-stamped sessions and their records without applying a version gate, while every delete endpoint remains version-agnostic so it can clean up data written by a newer client. Only the three mutating operations that can rewrite session-owned data apply the future-version guard: status updates, record appends, and learner merges return 409 FUTURE_VERSION rather than mutating a future-stamped session.

Status updates and record appends authorize and validate the session before entering the backing store's write transaction. If the store then rejects the write because a concurrent operation deleted or completed the session, the handler re-fetches the session instead of classifying the failure from driver or error-message text. A now-absent session returns 404 SESSION_NOT_FOUND; a session whose current status is no longer active returns 400 VALIDATION_FAILED and reports that status. If the structured re-fetch still finds an active, current-version session, or if the re-fetch itself fails, the original failure remains an undisclosed 500 INTERNAL_ERROR.


Packages @Openmaic Storage Docs Runtime Http Contract (`packages/@openmaic/storage/docs/runtime-http-contract.md`)

RuntimeStore HTTP contract

This contract exposes the complete RuntimeStore interface over JSON HTTP. All paths below are relative to a deployment-defined base URL. Path segments and query parameter values are percent-encoded UTF-8 strings. Identifiers used as path segments MUST NOT be exactly . or .., because URL parsers normalize those dot segments before routing. Request and response bodies use application/json; successful operations with no return value respond with 204 No Content.

Endpoints

Method Path Purpose Success
POST /runtime/sessions Create a session from a RuntimeSessionInit. The server stamps runtimeDslVersion; a client-submitted value is ignored. 201 with the full RuntimeSession
GET /runtime/sessions/{sessionId} Get one session. 200 with the RuntimeSession, or 404 if absent
PATCH /runtime/sessions/{sessionId}/status Set status from { "status", "updatedAt", "expectedLastSeq"? }. expectedLastSeq is a non-negative integer or null. 204
DELETE /runtime/sessions/{sessionId} Delete a session and all of its records. 204
GET /runtime/stages/{stageId}/learners/{learnerKey}/sessions List a partition's sessions, ordered by the instant represented by createdAt, then by id for deterministic ties. 200 with RuntimeSession[]
POST /runtime/sessions/{sessionId}/records Append a RuntimeRecordInit plus optional top-level expectedLastSeq and sessionTransition; body sessionId must match the path. 201 with the full RuntimeRecord
GET /runtime/sessions/{sessionId}/records List records ordered by seq. Optional ?sceneId={sceneId} returns only records anchored to that scene and excludes unanchored records. 200 with RuntimeRecord[]
POST /runtime/learners/merge Atomically re-key all sessions across all stages from { "fromLearnerKey", "toLearnerKey" }. 200 with { "moved": number }
DELETE /runtime/stages/{stageId}/learners/{learnerKey} Delete one learner's sessions and records on one stage. 204
DELETE /runtime/stages/{stageId} Cascade-delete every learner's sessions and records on one stage. 204
DELETE /runtime Delete every runtime session and record. Idempotent; an administrative operation — servers MUST gate it behind an operator-level authorization check, never expose it to learner credentials. 204

GET /runtime/sessions/{sessionId}/records returns an empty array when the session has no records, matching RuntimeStore.listRecords. For an absent session a server MAY answer 404 SESSION_NOT_FOUND instead of 200 [] — authorization-aware servers SHOULD, so absent and unowned sessions are indistinguishable — and the client MUST map that 404 back to an empty array, preserving the store contract. Deleting an absent target succeeds.

Server-assigned sequence

seq is server-assigned. The append request body is a RuntimeRecordInit. If it includes seq, that value is ignored; the store's assigned value is authoritative. The server allocates the next per-session monotonic sequence number atomically with the insert, starting at 0, and the response returns the full RuntimeRecord, including the assigned seq.

Likewise, runtimeDslVersion is server-assigned when a session is created. If the request body includes runtimeDslVersion, that value is ignored; the store's assigned value is authoritative.

Compare guards and session transitions

expectedLastSeq is an optional compare-and-swap guard shared by status updates and record appends. A non-negative integer means the caller observed that sequence at the record tail; null means the caller observed no records. Omission means no precondition. The server compares the value with the actual tail inside the same transaction as the write. A mismatch commits neither the status update nor the append and returns 409 RUNTIME_APPEND_CONFLICT with the expected and actual tails in error.details.

An append may also include a top-level sessionTransition object with { "status", "updatedAt" }. The server validates and commits the completed parent session in the same transaction as the record. A tail conflict or validation failure persists neither change. For example:

json
{
  "id": "record-1",
  "sessionId": "session-1",
  "createdAt": "2026-01-01T00:00:00.000Z",
  "payload": null,
  "expectedLastSeq": null,
  "sessionTransition": {
    "status": "completed",
    "updatedAt": "2026-01-01T00:00:01.000Z"
  }
}

Payload domain

HTTP implementations carry record payloads through JSON and therefore MUST accept only plain JSON values that survive serialization without changing meaning. They MUST fail loud before sending values such as Map, Set, Date, non-finite numbers, negative zero, nested undefined, bigint, sparse arrays, symbol-keyed properties, non-enumerable properties, arrays with non-index own properties, strings containing U+0000, class instances, and circular references. U+2028 and U+2029 are valid JSON string contents and MUST be accepted. This is intentionally narrower than BrowserRuntimeStore, whose structured-clone persistence can preserve values such as Map, Set, and Date that JSON cannot.

The reference handler streams request bodies into a bounded buffer. maxBodyBytes configures the bound on the runtime, composed-storage, and reference-server factories; it defaults to 32 MiB. A body that crosses the bound is rejected immediately with 413 PAYLOAD_TOO_LARGE.

learnerKey security model

learnerKey appears in paths, query parameters, and request bodies, but the server MUST derive or verify learnerKey from the authenticated session and MUST NOT blindly trust the client-submitted value. learnerKey is an opaque partition key, not proof of identity or authorization; trusting it verbatim creates a lateral-authorization vulnerability that lets one learner read, merge, or delete another learner's runtime data. The same rule applies to both keys in mergeLearner; authorization policy must explicitly permit the authenticated principal to migrate the source partition into the destination partition.

The conformance server in this package is test-only and does not implement an authentication or authorization model; deriving learnerKey from authentication is left to the reference server in #939 Part D.

Errors

Every non-2xx response has this machine-readable JSON shape:

json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "@openmaic/storage: invalid runtime session ...",
    "details": []
  }
}

details is optional. message preserves the semantic wording of the backing store error so an HTTP client can reconstitute the fail-loud exceptions callers receive from BrowserRuntimeStore.

Condition HTTP status Error code Client behavior
Malformed JSON, an invalid envelope or payload, invalid learner keys, a body/path mismatch, or append to a non-active session 400 VALIDATION_FAILED Throw HttpRuntimeStoreError (an Error) with the server message
Request body exceeds maxBodyBytes 413 PAYLOAD_TOO_LARGE Throw HttpRuntimeStoreError
Session does not exist 404 SESSION_NOT_FOUND getSession returns undefined; operations that require the session throw HttpRuntimeStoreError with the browser store's no session semantics
Route does not exist 404 ROUTE_NOT_FOUND Throw HttpRuntimeStoreError
A stored session has a future runtime DSL version 409 FUTURE_VERSION Throw HttpRuntimeStoreError with the browser store's fail-loud newer than this client's semantics
The store refused to create a session because the host considers its stage absent (RuntimeStageNotFoundError, for example a deleted course) 404 STAGE_NOT_FOUND Throw HttpRuntimeStoreError
Session id already exists, whichever learner holds it (the answer never depends on the holder, so a create cannot reveal whose session an id is) 409 SESSION_ALREADY_EXISTS Throw HttpRuntimeStoreError with the browser store's already exists semantics
expectedLastSeq does not match the current record tail 409 RUNTIME_APPEND_CONFLICT Reconstitute and throw RuntimeAppendConflictError with sessionId, expectedLastSeq, and actualLastSeq from error.details
Unexpected server failure 500 INTERNAL_ERROR Throw HttpRuntimeStoreError

The client MUST use the machine-readable code, not status alone, when an operation has special behavior. In particular, only SESSION_NOT_FOUND is translated to undefined by getSession. Sessions returned by reads MUST be forward-migrated through the client's migrateRuntime before they are returned, because the server may lag the client schema version. Corrupt sessions remain fail-loud on direct reads and are omitted from partition listings, matching BrowserRuntimeStore.

Retry and atomicity guarantees

mergeLearner, DELETE /runtime/sessions/{sessionId}, DELETE /runtime/stages/{stageId}/learners/{learnerKey}, and DELETE /runtime/stages/{stageId} MUST be safely retryable. Repeating a completed merge moves 0; repeating a delete succeeds with 204. A merge is atomic across every matching source session and MUST NOT expose a partial move. Delete endpoints cascade atomically to the target sessions' records.

The test-only conformance server may report a future-stamped-row conflict encountered mid-merge as 500 INTERNAL_ERROR; structured classification of that specific case is deferred to the real Part D server implementation.

POST /runtime/sessions and record append are not implicitly retry-safe: clients must not retry them after an ambiguous transport failure unless a deployment adds a separate idempotency-key policy. Session ids and record ids remain caller-owned uniqueness keys, while record seq remains exclusively server-owned.


Packages Docs Content Docs Configuration.Ar (`packages/docs/content/docs/configuration.ar.mdx`)


title: شرح الإعدادات

لدى OpenMAIC ثلاثة مصادر للإعدادات، ولكل منها مهمة واحدة:

المصدر من يكتبه ما يحتويه متى يسري
openmaic.yml المشغّل المزودون والتعيينات الافتراضية للخادم والخانات المقفلة، وما إذا كان المستخدمون يستطيعون إضافة مفاتيحهم عند بدء التشغيل (أعد التشغيل للتطبيق)
إعدادات النماذج في تطبيق الويب مستخدمو مساحة العمل مزودوهم الخاصون، وتعيينات كل خانة لا يقفلها openmaic.yml، وتُحفظ في قاعدة البيانات فورًا
متغيرات البيئة (.env.local، أو Secret الحاوية) المشغّل الأسرار التي يشير إليها openmaic.yml، وكل خيارات الخادم الأخرى عند بدء التشغيل

DATABASE_URL is required; other environment variables are optional (see Deployment). Providers that use server credentials for model selection also need authorized model lists, as described below. See .env.example for environment variables, openmaic.example.yml for a sample model configuration, and Supported models for preset and model IDs.

في الإصدارات السابقة كانت النماذج تُضبط فقط عبر متغيرات بيئة المزودين وserver-providers.yml وDEFAULT_MODEL وMODEL_ROUTES. ما زالت هذه الطرق تعمل حاليًا، باستثناء MODEL_ROUTES، لكنها مهملة؛ راجع الانتقال من الإعدادات القديمة.

النماذج والمزودون

المفاهيم

  • المزود (provider): حساب يستطيع الخادم استدعاءه، مثل مفتاح OpenAI أو Token Plan من MiniMax أو خادم متوافق مع OpenAI تستضيفه بنفسك. لكل مزود معرّف تختاره أنت، وإعداد مسبق (preset) يحدد المورّد والقدرات التي يغطيها.
  • الخانة (slot): موضع في المنتج يستخدم الذكاء الاصطناعي، مثل مخطط المقرر أو محتوى الشرائح أو تحويل النص إلى كلام أو البحث على الويب. تشكّل الخانات شجرة لكل قدرة، والخانة التي ليس لها تعيين تتبع الخانة الأم.
  • التعيين (assignment): المزود والنموذج اللذان تستخدمهما الخانة (provider:model)، مع معاملات استدعاء ونموذج احتياطي اختياريًا، أو null لإيقاف الخانة.

تكون القدرة متاحة فقط عندما تُحل خانتها إلى تعيين. لا توجد مفاتيح تشغيل وإيقاف منفصلة.

openmaic.yml

يقرأ الخادم openmaic.yml من المجلد الذي يبدأ فيه (جذر المستودع؛ /app في صورة Docker). لاستخدام مسار آخر اضبط OPENMAIC_CONFIG؛ يُحل المسار النسبي انطلاقًا من المجلد نفسه، ويجب عندها أن يكون الملف موجودًا. بدون الملف يستخدم الخادم إعدادات الويب، والمتغيرات القديمة ما دامت مضبوطة.

يُقرأ الملف مرة واحدة عند بدء التشغيل: أعد تشغيل الخادم بعد تعديله. يُتحقق من الملف كاملًا قبل بدء الخادم. أي ${VAR} غير مضبوط، أو إعداد مسبق أو خانة غير معروفين، أو إشارة إلى مزود غير مُعلن، أو مزود لا يقدم قدرة الخانة، يوقف الخادم، وتُعرض كل مشكلة مع موضعها:

text
[boot] Invalid server configuration; the server will not start: Invalid model configuration in /app/openmaic.yml:
  - providers.openai.apiKey: environment variable OPENAI_API_KEY is not set
  - slots.course.outlines: unknown slot

لا تكتب الأسرار في الملف نفسه: أشر إليها بـ ${VAR} واضبط المتغير في البيئة. القيم الآتية من المتغيرات لا تُطبع أبدًا في هذه الرسائل.

مثال كامل

text
/* Detailed source-code truncated for AI context efficiency. */

مع هذا الملف تكون الخانات المكتوبة تحت slots قيمًا افتراضية للخادم: يمكن للمستخدمين تغيير كل منها في إعدادات النماذج، باستثناء tts الذي يثبّته lock للجميع. خانة المحادثة غير المذكورة، مثل course.agents، تتبع أصلها (llm)؛ وجذر القدرة غير المذكور، مثل asr أو document، يبقى غير مُهيأ حتى يعيّنه مستخدم. ومع allowUserKeys: false يختار المستخدمون فقط من بين المزودين المعلنين هنا.

المزودون

كل إدخال تحت providers يعلن مزودًا واحدًا. المفتاح هو معرّفه الذي تستخدمه الخانات للإشارة إليه: من 1 إلى 63 حرفًا من الأحرف الصغيرة والأرقام والشرطات، يبدأ بحرف أو رقم.

الحقل إلزامي المعنى
preset نعم الإعداد المسبق المدمج الذي يستخدمه هذا المزود. راجع النماذج المدعومة ← الإعدادات المسبقة.
apiKey عادةً مفتاح API. تحذفه الخدمات التي لا تحتاج إلى مفتاح (الخوادم المحلية، Brave، SearXNG، unpdf).
baseUrl لبعض الإعدادات المسبقة عنوان يُستدعى بدلًا من عنوان الإعداد المسبق. إلزامي لـ openai-compatible وazure وsearxng وmineru.
models For server-managed credentials Authorized model ids. Deployment slot defaults/fallbacks for the capability are also authorized; catalogues and workspace assignments do not grant additional models.
proxy لا عنوان وكيل HTTP لطلبات هذا المزود.
credentials لبعض الإعدادات المسبقة أزواج مفاتيح للخدمات التي لا تستخدم مفتاحًا واحدًا. يستخدم AliDocMind الحقلين accessKeyId وaccessKeySecret.
options لا إعدادات غير سرية خاصة بالمزود، مثل backend لـ voxcpm-tts. الأسماء أحرف وأرقام و_ و-؛ والقيم سلاسل نصية أو أرقام أو قيم منطقية (بلا خرائط أو قوائم متداخلة)، ويعمل ${VAR}. تعرض إعدادات النماذج الخيارات، لذا لا تضع فيها مفتاحًا أبدًا. للنشر فقط: لا تستطيع إعدادات النماذج ضبطها.

تغطي معظم الإعدادات المسبقة قدرة واحدة وفق السجلات المدمجة: الإعداد openai للمحادثة فقط، أما صور OpenAI وتحويل النص إلى كلام والتفريغ الصوتي فهي الإعدادات openai-image وopenai-tts وopenai-whisper. أعلن مزودًا واحدًا لكل قدرة، وأشر إلى ${VAR} نفسه عندما يغطي مفتاح واحد عدة قدرات. إعدادات Token Plan المسبقة (minimax وtokendance وvolcengine-ark وkimi-coding-plan) تغطي عدة قدرات بمفتاح واحد، كل منها على عنوان الخطة الخاص. يستبدل baseUrl على المزود العنوان لكل القدرات التي يغطيها، لذا لا تضع baseUrl لـ Token Plan إلا إذا انتقلت كل قدراته معه.

يوقّع Amazon Bedrock (preset: bedrock) الطلبات بسلسلة بيانات اعتماد AWS على الخادم (البيئة أو profile أو role)، ويقرأ المنطقة من BEDROCK_REGION أو AWS_REGION أو AWS_DEFAULT_REGION؛ وapiKey رمز bearer اختياري.

الخانات

الخانة القدرة الأم ما تتحكم فيه
llm chat — نموذج المحادثة الافتراضي: كل استدعاء محادثة لا تغطيه خانة أدناه
course.research chat llm إعادة صياغة استعلام البحث على الويب أثناء البحث للمقرر
course.outline chat llm مخططات المشاهد
course.agents chat llm ملفات تعريف وكلاء المعلم وزملاء الصف
course.content chat llm محتوى المشاهد لأنواع المشاهد التي ليس لها خانة خاصة
course.content.slide chat course.content مشاهد الشرائح
course.content.quiz chat course.content مشاهد الاختبارات
course.content.interactive chat course.content المشاهد التفاعلية
course.content.pbl chat course.content مشاهد التعلم القائم على المشاريع (PBL)
course.actions chat llm إجراءات المشاهد (ما يقوله الوكلاء ويفعلونه في كل مشهد)
classroom chat llm محادثة الصف وتصحيح الاختبارات وبيئة تشغيل PBL
agent chat llm وكيل Pro workbench. يتطلب استدعاء الأدوات. ويقبل أيضًا api وcontextWindow
agent.title chat agent عناوين جلسات Pro. تُضبط في openmaic.yml فقط ولا تظهر في إعدادات النماذج
tts tts — تحويل النص إلى كلام
asr asr — التعرف على الكلام (الإدخال الصوتي وتفريغ الصوت والفيديو)
image image — توليد الصور
video video — توليد الفيديو
webFetch webFetch — استخراج صفحات الويب (الافتراضي: builtin)
webSearch webSearch — البحث على الويب
document document — خدمة تحليل المستندات (MinerU وMinerU Cloud وAliDocMind). لا يحتاج استخراج نص PDF المدمج إلى خدمة

كيف تُحل الخانة:

  • يبدأ الحل من خانة الاستدعاء ويصعد نحو جذر القدرة، وأول تعيين يُعثر عليه هو الذي يُستخدم. لذلك يكون تعيين الخانة الأم هو الافتراضي لشجرتها الفرعية كلها، وتغيير الأم يغيّر كل خانة فرعية ليس لها تعيين خاص.
  • داخل شجرة فرعية مقفلة (الخانة أو أحد أسلافها مذكور في lock، أو lock: all) لا يُعتد إلا بـ openmaic.yml: يصعد البحث حتى الخانة المقفلة، وأول تعيين يكتبه الملف هو الذي يُستخدم. وفي كل مكان آخر يغلب اختيار المستخدم: يُبحث أولًا عن تعيين مساحة العمل نفسها من الخانة صعودًا حتى الجذر، ولا يُبحث عن القيم الافتراضية التي يكتبها openmaic.yml إلا إذا لم يوجد أي تعيين، ومن الخانة صعودًا أيضًا. لذلك تتبع الخانة التي لم تغيّرها مساحة العمل اختيارَها على الخانة الأصل، حتى لو كُتبت عليها قيمة افتراضية للخادم؛ فقيم الخادم ليست إلا قيمًا افتراضية.
  • null يوقف الصعود: الخانة وكل الخانات تحتها التي ليس لها تعيين خاص تكون موقوفة.
  • إذا وصل الحل إلى الجذر دون تعيين، تبقى القدرة غير مُعدة. تفشل الاستدعاءات بخطأ يذكر اسم الخانة، ولا يوجد رجوع إلى أي مورّد.
  • المتطلبات تخص الخانة التي تعلنها، وتُفحص على النموذج الذي تُحل إليه الخانة، سواء كان معيّنًا لها أو موروثًا. تتطلب agent استدعاء الأدوات؛ وإذا قال الكتالوج المدمج إن نموذجها لا يدعمه، يرفض الوكيل العمل بدل أن يتدبر أمره. النموذج غير الموجود في الكتالوج مسموح به. لا متطلبات لـ agent.title، لذا يمكن أن يستخدم نموذجًا أصغر.

التعيينات

للتعيين أربعة أشكال:

yaml
slots:
  llm: openai:gpt-5.5            # مزود:نموذج
  webSearch: tavily              # المزود وحده: نموذجه الافتراضي
  course.outline:                # كائن مع معاملات
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
    thinking: { effort: low }
  video: null                    # موقوف

يقبل شكل الكائن الحقول التالية:

الحقل المعنى
model إلزامي. مزود:نموذج، أو مزود وحده خارج خانات المحادثة.
fallback نموذج لإعادة المحاولة مرة واحدة، بالشكل نفسه. لخانات النماذج اللغوية فقط؛ راجع النماذج الاحتياطية.
thinking معاملات الاستدلال لنماذج المحادثة: enabled (true/false)، وmode (default وdisabled وenabled وauto)، وeffort (none وminimal وlow وmedium وhigh وxhigh وmax)، وlevel (minimal وlow وmedium وhigh)، وbudgetTokens، وexcludeReasoningOutput. يطبّق محوّل كل مزود ما تدعمه واجهته.
api لـ agent فقط: لهجة النقل، openai-completions (الافتراضية) أو openai-responses.
contextWindow لـ agent فقط: حجم نافذة السياق المفترض عند الضغط، عندما يجب أن يكون أقل من قيمة الكتالوج.

يجب ألا تضبط خانة agent القيمة thinking.effort: فالنقل الذي يستدعي الأدوات لا يستطيع الجمع بين شدة الاستدلال وأدوات الدوال.

الإشارة إلى المزود وحده

الإشارة بدون :model تعني النموذج الافتراضي للمزود: النموذج الذي توصي به Token Plan لتلك القدرة، وإلا فالنموذج الافتراضي في السجل المدمج. معظم خدمات البحث والمستندات لا تتيح اختيار نموذج، لذا فالشكل المعتاد هو webSearch: tavily أو document: mineru-cloud. خانات المحادثة تسمّي نموذجًا دائمًا؛ وllm: openai يُرفض عند بدء التشغيل.

النماذج الاحتياطية

تنطبق النماذج الاحتياطية على خانات النماذج اللغوية فقط: llm وكل الخانات تحته. يُرفض fallback في خانة tts أو asr أو image أو video أو webSearch أو webFetch أو document، عند بدء التشغيل في openmaic.yml وعند الحفظ في إعدادات النماذج.

يُجرَّب fallback الخاص بالخانة مرة واحدة عندما يفشل استدعاء النموذج الأساسي، بعد محاولاته الخاصة، بفشل قابل لإعادة المحاولة: خطأ عابر من SDK، أو انتهاء المهلة، أو خطأ شبكة، أو تجاوز حد المعدل (429)، أو نقص السعة (503)، أو مخرجات فارغة. أما إذا رفض المزود الطلب صراحةً لنفاد الحصة (برمز متوافق مع OpenAI مثل insufficient_quota، أيًّا كانت حالة HTTP)، فلا يُعاد الاستدعاء على النموذج الأساسي، بل ينتقل فورًا إلى النموذج الاحتياطي. رفض المحتوى لأسباب السلامة وأخطاء 4xx الأخرى لا تؤدي إلى الرجوع الاحتياطي. ينطبق الرجوع الاحتياطي على الاستدعاءات العادية والمتدفقة معًا، ويستخدم معاملات thinking الخاصة بالخانة.

النموذج الاحتياطي جزء من التعيين ويُورث معه: course.content.slide بلا تعيين خاص تستخدم نموذج course.content ونموذجه الاحتياطي. الخانة ذات المتطلبات لا تعيد المحاولة على نموذج احتياطي لا يلبيها.

إيقاف قدرة

اكتب null في خانتها. video: null يوقف توليد الفيديو افتراضيًا، ويظل بإمكان المستخدمين تشغيله في إعدادات النماذج؛ أضف lock: [video] لإبقائه موقوفًا للجميع. وcourse.content.pbl: null يوقف توليد مشاهد PBL بينما يواصل باقي course.content العمل. يفشل استدعاء الخانة الموقوفة فورًا، وتجيب مسارات الوسائط بـ 403 بدل اختيار مورّد آخر.

القيم الافتراضية والأقفال وإعدادات النماذج

ما يكتبه openmaic.yml تحت slots هو القيمة الافتراضية للخادم: يمكن للمستخدمين تغييرها في إعدادات النماذج، حيث تقول البطاقة افتراضي الخادم، ويلغي إعادة التعيين إلى افتراضي الخادم تغييرهم. وما يذكره تحت lock يثبّته المسؤول للجميع مع شجرته الفرعية كاملة:

yaml
lock: [llm, video]   # هذه الخانات وكل الخانات تحتها
# lock: all          # كل الخانات
  • يجب أن تكون كل خانة يذكرها lock مكتوبة تحت slots (وnull يُحتسب: video: null مع lock: [video] يبقي الفيديو موقوفًا للجميع). الخانة المجهولة، أو الخانة المقفلة التي لا يكتبها slots، توقف الخادم عند بدء التشغيل مع ذكر موضعها (lock.1: ...).
  • الخانة المقفلة تثبّت أبناءها أيضًا: مع lock: [llm] لا يستطيع أي مستخدم ضبط course.outline أو أي خانة أخرى لنموذج اللغة، وتُحل كلها من openmaic.yml وحده.
  • lock: all يثبّت كل الخانات، مكتوبة أو لا؛ ويصبح جذر القدرة الذي لا يكتبه slots غير مُهيأ للجميع. لكن webFetch غير المحدد يستخدم builtin؛ ولتعطيله اضبط webFetch: null.
  • تجتمع null والقيمة الافتراضية والقفل كما هو متوقع: video: null وحدها موقوفة حتى يشغّلها مستخدم؛ ومع lock: [video] تبقى موقوفة.

تأخذ الإعدادات أحد ثلاثة أشكال، بحسب الإعداد:

الشكل متى ما يُعرض
اضبطها بنفسك allowUserKeys مفعّل (الافتراضي) Token Plan وخدمات النماذج وإعدادات نماذج الدورة
اختر نموذجًا allowUserKeys: false إعدادات نماذج الدورة فقط، مع الاختيار من بين المزودين الذين يعلنهم openmaic.yml
مُهيأ من قِبل المسؤول كل الخانات مقفلة (lock: all) مخطط نماذج الدورة، وكل بطاقة فيه للقراءة فقط (تعرض نموذجها، أو أنها موقوفة أو غير مضبوطة)

قاعدة واحدة تحكم كل عنصر تحكم: لا يُعرض إلا إذا كان استخدامه يمكن أن يغيّر شيئًا. لا يظهر تبويب في خدمات النماذج، ولا إضافة خدمة فيه، إلا ما دامت لقدرته خانة يستطيع المستخدم ضبطها؛ ولا يظهر Token Plan إلا ما دامت خطة ما قادرة على ملء خانة غير مقفلة، ولا يمس ربط الخطة أي خانة مقفلة. البطاقة المقفلة في إعدادات نماذج الدورة سطر واحد للقراءة فقط عليه ثبّته المسؤول. ولا يظهر منتقي النماذج في الصفحة الرئيسية إلا عندما يمكن تغيير النموذج الافتراضي (llm غير مقفلة ويقدّم مزود ما نموذج لغة).

تقرأ إعدادات النماذج في تطبيق الويب الشجرة نفسها وتعدّلها لمساحة العمل الحالية:

  • Token Plan: يضيف ربط خطة مزودها بالمفتاح ويملأ الخانات التي توصي بها الخطة ولم تُعيّن بعد. يزيل فك الربط المزود، وتعود الخانات التي كانت تستخدمه إلى اتباع خاناتها الأم.
  • خدمات النماذج: تبويب لكل قدرة (نماذج اللغة، الصور، الفيديو، تحويل النص إلى كلام، التعرف على الكلام، تحليل المستندات، البحث على الويب) يسرد خدماتها. يُعرض مزودو openmaic.yml للقراءة فقط ودون أي معلومات عن مفاتيحهم. الخدمة التي تعدّها مساحة العمل تصبح مزودًا لها: مفتاحها للكتابة فقط ولا يظهر إلا كقناع مثل …abcd، والعنوان المخصص متاح لنماذج اللغة فقط (تستخدم Azure Speech نقطة النهاية الرسمية لمنطقتها، مثل https://<region>.tts.speech.microsoft.com)، والخدمات المستضافة ذاتيًا وأزواج المفاتيح تُعدّ في openmaic.yml. تتحقق أزرار الاختبار من المزود المحفوظ على الخادم.
  • إعدادات نماذج الدورة: النموذج الرئيسي هو خانة llm، ولكل مرحلة في المسار خانتها (اتباع النموذج الرئيسي يعني أنه لا تعيين خاصًا بالمرحلة)؛ وتضبط مفاتيح الصور والفيديو والكلام والبحث والتفريغ خانتها على null. الخانة المقفلة سطر واحد للقراءة فقط، والخانة التي على قيمة الخادم الافتراضية تعرض إعادة التعيين إلى افتراضي الخادم بعد أن يغيّرها المستخدم.

تُستورد إعدادات المزودين التي حفظها المتصفح في الإصدارات السابقة (المزودون بمفاتيحهم وعناوينهم الأساسية، والنموذج المختار، والاشتراك في Token Plan، واختيار كل قدرة) إلى مساحة العمل مرة واحدة. لا يستبدل الاستيراد أي إعداد موجود في مساحة العمل ولا أي خانة يقفلها openmaic.yml، ويتخطى العناصر التي يرفضها allowUserKeys: false أو التحقق. بعد استجابة 2xx، لا يُحذف من المتصفح إلا ما أصبحت مساحة العمل تحتفظ به: كل عنصر تخطاه الخادم (عنوان مخصص لخدمة وسائط، أو إعداد مسبق لا يحق لمساحة العمل استخدامه، أو معرّف مزود يعلنه النشر، ...) يبقى في هذا المتصفح مع مفتاحه، ولا يُرسل مرة أخرى، ويُعرض مع السبب في الإعدادات ← خدمات النماذج (مع تنبيه لمرة واحدة) حتى يُعاد إعداده أو يُتجاهل. يُستورد مزود Azure Speech مع نقطة النهاية الرسمية لمنطقته. بعد 400 يُرفض الاقتراح كله: لا يُرسل مرة أخرى، وتبقى عناصره مع مفاتيحها في هذا المتصفح وتُعرض مع العناصر التي تم تخطيها. ويُحذف الاقتراح غير المقروء؛ ولا يبقى في المتصفح لمحاولة لاحقة إلا عند 401 أو 404 أو 409 أو 5xx أو خطأ في الشبكة. يصبح الإدخال الصوتي الذي أُوقف صراحةً asr: null، وتصبح السرد أو الصور أو الفيديو التي أُوقفت مع وجود مزود صالح لها tts: null أو image: null أو video: null (وتبقى الخانة التي يقفلها openmaic.yml كما هي). يجب إعداد ما يلي يدويًا من جديد: نماذج المراحل (في إعدادات نماذج الدورة، أو slots في openmaic.yml)، وإعدادات الاستدلال (thinking على الخانة)، ومزودو الكلام والتفريغ المخصصون وزوج مفاتيح AliDocMind (في openmaic.yml؛ تبقى في المتصفح وتُعرض مع العناصر المتخطاة)، وخلفية VoxCPM (options.backend)، ومصادر بحث Baidu الفرعية (تُطبّق القيم الافتراضية للخادم). لا يُنقل إيقاف البحث (كان يوقف بحث الدورة فقط، بينما واصلت المحادثة والوكيل البحث)، ولا المفتاح المتوقف بحكم القيمة الافتراضية فقط: كل قدرة تعمل الآن متى حُلت خانتها.

مساحة العمل هي المالك (owner) بمعنى توثيق التخزين الدائم: في إعداد Docker Compose الافتراضي (وضع المستخدم الواحد) توجد مساحة عمل واحدة، وفي النشر متعدد المستخدمين يكون لكل حساب مسجّل مساحة عمله.

allowUserKeys

yaml
allowUserKeys: false

يحدد allowUserKeys (الافتراضي true) ما إذا كان بإمكان المستخدمين إضافة مزودين ومفاتيح وخطط Token Plan خاصة بهم. مع false يختار المستخدمون فقط من بين المزودين الذين يعلنهم openmaic.yml، وللخانات غير المقفلة فقط: لا يُعرض Token Plan ولا إضافة الخدمات، ولا تُستخدم بعد ذلك المزودات التي أضافتها مساحات العمل من قبل. ويتم حينئذ تجاهل الطلبات التي لا تزال تسمّي نموذجها أو مفتاحها أو عنوانها (ترويسات x-model وx-api-key وx-base-url المهملة، وحقول المزود في مسارات الوسائط)، ويُرفض شكل الترويسات في أزرار اختبار المزودين. والنشر متعدد المستخدمين الذي يتحمّل كل الاستخدام يضبطه عادةً ويقفل الخانات التي يريد تثبيتها.

الأسرار و OPENMAIC_SECRET_KEY

  • المفاتيح في openmaic.yml تأتي من البيئة عبر ${VAR}. لا تُخزن في قاعدة البيانات ولا تُرسل إلى المتصفح أبدًا.
  • المفاتيح التي تدخلها مساحة العمل في إعدادات النماذج تُخزن في قاعدة البيانات مشفرة بـ AES-256-GCM تحت سر المثيل OPENMAIC_SECRET_KEY.
  • عندما لا يكون OPENMAIC_SECRET_KEY مضبوطًا، ينشئ الخادم سرًا عشوائيًا عند أول استخدام في data/instance-secret.key (داخل وحدة التخزين openmaic-data مع Docker Compose) ويستخدمه بعد ذلك.

احفظ السر مع نسخ قاعدة البيانات الاحتياطية: المفاتيح المحفوظة تحت سر مفقود أو متغير لا يمكن قراءتها ويجب إدخالها من جديد (تشير إليها إعدادات النماذج). اضبط OPENMAIC_SECRET_KEY صراحةً عندما تتشارك عدة مثيلات قاعدة البيانات نفسها، أو عندما لا يكون للخادم مجلد بيانات دائم (مثل حاوية بلا وحدة تخزين للبيانات)؛ أي سلسلة نصية تصلح، مثل مخرجات openssl rand -base64 32.

من دونه، يعني مجلد البيانات الذي لا يبقى بعد إعادة التشغيل (حاوية بلا وحدة تخزين، أو قرص مؤقت) سرًا جديدًا عند كل تشغيل، وتنشئ كل نسخة من النسخ المتعددة سرها الخاص؛ وفي مجلد عمل للقراءة فقط لا يمكن إنشاء سر ويفشل حفظ المفاتيح في إعدادات النماذج. عند بدء التشغيل يسجّل الخادم تحذيرًا عندما لا يكون OPENMAIC_SECRET_KEY مضبوطًا ومجلد البيانات غير قابل للكتابة، وعندما تحتوي قاعدة البيانات على مفاتيح مختومة بسر غير السر الحالي (مثلًا لفقدان data/instance-secret.key)؛ ويواصل التشغيل مع ذلك.

bash
OPENMAIC_SECRET_KEY=...        # سر المثيل للمفاتيح المحفوظة في إعدادات النماذج
OPENMAIC_CONFIG=/etc/openmaic/openmaic.yml   # اختياري: مسار آخر لـ openmaic.yml

ما يمكن لمساحات العمل إضافته

يمكن أن يكون مزودو openmaic.yml أي شيء يقبله المخطط. أما المزودون الذين تضيفهم مساحة العمل في إعدادات النماذج فهم أكثر تقييدًا، لأن عنوانهم ومفتاحهم يكتبهما مستخدم تطبيق الويب:

  • لا يُعرض Amazon Bedrock: فهو سيوقّع ببيانات اعتماد AWS الخاصة بالخادم.
  • لا proxy ولا أزواج مفاتيح (credentials): الإعدادات المسبقة التي تستخدم زوج مفاتيح، مثل AliDocMind، متاحة للنشر فقط.
  • يُسمح بـ baseUrl مخصص فقط للإعدادات المسبقة التي تقدم المحادثة، والمزود الذي يملكه يخدم خانات المحادثة فقط. يجب ألا يحتوي العنوان على بيانات اعتماد أو سلسلة استعلام أو جزء (fragment)، ويجب أن يُحل إلى عنوان عام ما لم يضبط الخادم ALLOW_LOCAL_NETWORKS=true. الطلبات إليه لا تتبع عمليات إعادة التوجيه.
  • يجب على خوادم النماذج المحلية (Ollama وLemonade) تسمية عنوانها الخاص بدل localhost الافتراضي في الإعداد المسبق.
  • خدمات الوسائط والبحث والمستندات المستضافة ذاتيًا (VoxCPM2 وFunASR وكلام وصور Lemonade وComfyUI وSearXNG وMinerU المستضاف ذاتيًا) تعمل على شبكة الخادم نفسه ومتاحة للنشر فقط.
  • لا يُعرض المزودون الذين أوقفهم المشغّل بمتغير <CAP>_<VENDOR>_ENABLED=false القديم.
  • لا يمكن لمساحة العمل إعلان معرّف مزود أعلنه openmaic.yml بالفعل.

إذا أراد النشر أن يصل المستخدمون إلى خدمات داخلية، فليعلن هؤلاء المزودين في openmaic.yml.

عملاء API وحقول الطلب المهملة

لم تعد طلبات التوليد بحاجة إلى حمل مفاتيح أو اختيارات نماذج: استدعاءات API التي تعمل بلا متصفح، مثل /api/generate-classroom، تُحل عبر الخانات نفسها التي يستخدمها تطبيق الويب، لمساحة عمل المستدعي.

ترويسات الطلب القديمة x-model وx-api-key وx-base-url وx-provider-type وx-model-routes وx-image-provider وx-image-model وx-video-provider وx-video-model، وحقول جسم الطلب المقابلة في مسارات الكلام والبحث والمستندات، مهملة. لا يُعمل بها إلا ما دامت الخانة التي تؤثر فيها بلا تعيين، أو، على خادم لا يزال مُهيأً بالمتغيرات القديمة، بقيمة افتراضية محوَّلة منها فقط (فقد كان DEFAULT_MODEL دائمًا أدنى من النموذج الذي يسميه الطلب)؛ وتغلب القيمة الافتراضية في openmaic.yml واختيار مساحة العمل نفسها والخانة المقفلة، والخانة المضبوطة على null ترفض الطلب أيًا كان ما يسميه. ومع allowUserKeys: false يتم تجاهلها كليًا. يسجّل الخادم تحذيرًا في أول مرة يستخدمها طلب. ستُزال بعد إصدارين فرعيين من صدور إعدادات النماذج الموصوفة أعلاه.

الانتقال من الإعدادات القديمة

عندما لا يوجد openmaic.yml، يحوّل الخادم الإعدادات القديمة عند بدء التشغيل ويسجّل إشعار إهمال، وإشعارًا لكل إدخال تعذّر نقله:

الإعداد القديم يصبح
متغيرات المزودين (OPENAI_API_KEY وTTS_MINIMAX_API_KEY و…) وserver-providers.yml مزودي النشر (للقراءة فقط في إعدادات النماذج). معرّف المزود هو معرّف الإعداد المسبق، وهو معرّف المزود القديم باستثناء بحث MiniMax على الويب (minimax-search) وبحث Doubao على الويب (doubao-search) وصور Lemonade (lemonade-image)
_MODELS models الخاصة بالمزود
DEFAULT_MODEL تعيين llm كقيمة افتراضية يمكن لمساحة العمل تجاوزها (غير مقفلة). تبقى خانة agent موقوفة كما كانت
MODEL_FALLBACK النموذج الاحتياطي لـ DEFAULT_MODEL. بدون DEFAULT_MODEL يُسقط مع إشعار
أول مزود مُعد للـ TTS والـ ASR والصور والفيديو والمستندات، والبحث على الويب بترتيب الأولوية القديم التعيين الافتراضي لتلك القدرة (يختار DEFAULT_IMAGE_PROVIDER مزود الصور)
MODEL_ROUTES لا يُحوَّل. يرفض الخادم البدء (انظر أدناه)
<CAP>_<VENDOR>_ENABLED=false ما زال ساريًا: يبقى المزود موقوفًا أيًا كان من يعيّنه

عند وجود openmaic.yml تُتجاهل متغيرات المزودين وserver-providers.yml وDEFAULT_MODEL وMODEL_FALLBACK وMODEL_ROUTES في حل النماذج، مع إشعار. ما زال <CAP>_<VENDOR>_ENABLED=false ساريًا؛ لإيقاف قدرة اضبط خانتها على null بدلًا من ذلك.

متغيرات البيئة. قبل:

bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=openai:gpt-5.5
MODEL_FALLBACK=anthropic:claude-sonnet-4-6
TTS_MINIMAX_API_KEY=...
TAVILY_API_KEY=tvly-...

بعد (تبقى المفاتيح في البيئة):

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  minimax-tts:
    preset: minimax-tts
    apiKey: ${TTS_MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}

slots:
  llm:
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
  tts: minimax-tts
  webSearch: tavily

احذف بعد ذلك DEFAULT_MODEL وMODEL_FALLBACK. يمكن إبقاء متغيرات المزودين مصدرًا لإشارات ${VAR}. يصبح _BASE_URL قيمة baseUrl للمزود (OPENAI_BASE_URL ← baseUrl تحت openai؛ اكتب العنوان، أو ${OPENAI_BASE_URL} ما دام المتغير مضبوطًا)، ويصبح _MODELS قائمة models الخاصة به. ومثل DEFAULT_MODEL المحوَّل، فإن llm المكتوب هنا قيمة افتراضية للخادم يمكن لمساحات العمل تغييرها؛ أضف lock: [llm] لتثبيته للجميع. وعلى خلاف التحويل، أصبحت agent تتبع llm: اكتب agent: null لإبقاء وكيل Pro موقوفًا.

server-providers.yml. قبل:

yaml title="server-providers.yml"
providers:
  azure:
    apiKey: ...
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
pdf:
  alidocmind:
    accessKeyId: ...
    accessKeySecret: ...
web-search:
  searxng:
    baseUrl: http://searxng:8080

بعد:

yaml title="openmaic.yml"
providers:
  azure:
    preset: azure
    apiKey: ${AZURE_OPENAI_API_KEY}
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
  alidocmind:
    preset: alidocmind
    credentials:
      accessKeyId: ${ALIDOCMIND_ACCESS_KEY_ID}
      accessKeySecret: ${ALIDOCMIND_ACCESS_KEY_SECRET}
  searxng:
    preset: searxng
    baseUrl: http://searxng:8080

slots:
  llm: azure:my-deployment
  document: alidocmind
  webSearch: searxng

يصبح كل قسم من server-providers.yml مزودين بالإعداد المسبق المطابق (providers ← إعدادات المحادثة، وtts وasr وpdf ← المستندات، وimage وvideo وweb-search)، وينتقل accessKeyId/accessKeySecret إلى credentials.

MODEL_ROUTES. الخادم الذي يضبط MODEL_ROUTES دون openmaic.yml لا يبدأ:

text
MODEL_ROUTES does not carry over to the model configuration: write the per-stage models as slots in openmaic.yml (or the file named by OPENMAIC_CONFIG) and remove MODEL_ROUTES

لا تتطابق المسارات حسب المرحلة مع الخانات واحدًا لواحد، لذا لا تُحوَّل. أعد كتابة كل مسار كالخانة التي تنتمي إليها مرحلته:

مرحلة MODEL_ROUTES الخانة
web-search-query-rewrite course.research
scene-outlines-stream course.outline
agent-profiles course.agents
scene-content course.content
scene-content:slide و:quiz و:interactive و:pbl course.content.slide و.quiz و.interactive و.pbl
scene-actions course.actions
chat-adapter وquiz-grade وpbl-v2-runtime ومتغيراته :instructor و:open-task و:evaluate و:simulator classroom
generate-classroom llm
maic-agent-driver agent
conversation-title agent.title
pbl-chat وmaic-agent لا شيء: أُزيلت هذه المراحل. احذفها

عندما تتشارك عدة مراحل خانة واحدة، أعطِ الخانة النموذج الواحد الذي يجب أن تستخدمه. ينتقل thinking الخاص بالمسار إلى thinking الخانة، وينتقل api وcontextWindow من مسار maic-agent-driver إلى خانة agent. قبل:

bash
DEFAULT_MODEL=openai:gpt-5.5
MODEL_ROUTES='{"scene-outlines-stream":"anthropic:claude-sonnet-4-6","scene-content:slide":{"model":"openai:gpt-5.4","thinking":{"effort":"low"}},"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'

بعد:

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}

slots:
  llm: openai:gpt-5.5
  course.outline: anthropic:claude-sonnet-4-6
  course.content.slide:
    model: openai:gpt-5.4
    thinking: { effort: low }
  agent:
    model: openai:gpt-5.5
    api: openai-completions

بمجرد وجود openmaic.yml لا تضبط متغيرات المزودين وحدها أي شيء: مزود تحويل النص إلى كلام أو الصور أو المستندات أو البحث على الويب الذي لم يكن له سوى متغيراته (TTS_MINIMAX_API_KEY وIMAGE_SEEDREAM_API_KEY وPDF_MINERU_BASE_URL وTAVILY_API_KEY …) لم يعد يُستخدم، ولا تحصل القدرات الأخرى، باستثناء webFetch، على مزود افتراضي. صرّح بكل مزود تستخدمه تحت providers وعيّن tts وimage وdocument وwebSearch (وasr وvideo) في slots، كما في مثال متغيرات البيئة أعلاه؛ وتبقى الخانات الأخرى غير المعيّنة غير مُعدّة ما لم تعيّنها مساحة عمل في إعدادات النماذج.

ثم احذف MODEL_ROUTES وDEFAULT_MODEL وMODEL_FALLBACK.

Docker. اربط الملف بجوار التطبيق وأبقِ المفاتيح في .env.local:

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

ACCESS_CODE — كلمة مرور على مستوى الموقع

للنشر المشترك (عروض داخلية، صفوف)، يمكن تعيين رمز وصول ليطلب من الزوار إدخال كلمة مرور قبل رؤية التطبيق:

bash
ACCESS_CODE=your-secret-code

استخدم قيمة عشوائية طويلة (16 حرفًا على الأقل) — فهذا الرمز هو السر الوحيد الذي يحمي النشر. بعد نجاح التحقق، يُحفظ رمز موقَّع في cookie من نوع HTTP-only لمدة 7 أيام، ويُفرض انتهاء صلاحيته من جهة الخادم، لذا يلزم إعادة إدخال الرمز بعد انتهائه. لا يُقيَّد معدل التحقق إلا عند ضبط TRUST_PROXY_HEADERS=true خلف وكيل عكسي موثوق يعيد كتابة x-forwarded-for / x-real-ip: لكل عميل 10 محاولات كل 60 ثانية، ونجاح التحقق يمسح عدّاد ذلك العميل. وبدون وكيل موثوق لا يستطيع التطبيق نسبة الطلب إلى عميل بعينه، لذا لا يوجد أي تقييد، وتكون الحماية في طول الرمز وعشوائيته. اتركه فارغًا لتعطيله.

feature flags (مفاتيح الميزات)

تُعتبر القيم true أو 1 مفعلة، وما لم يُذكر خلاف ذلك تُعد أي قيمة أخرى معطلة. تُحقن متغيرات NEXT_PUBLIC_* في وقت البناء للعميل، ويجب إعادة البناء بعد تعديلها:

bash
# محرر MAIC (وضع Pro)
NEXT_PUBLIC_MAIC_EDITOR_ENABLED=true

# مراجع مواد PPT والمشاهد التفاعلية أثناء تشغيل Pi (علامة وقت البناء؛ لا تؤثر في مراجع وضع التحرير)
NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED=true

# محرك مهام التعليم المهني (مفتاح خادم)
OPENMAIC_ENABLE_VOCATIONAL=true

# عرض مفتاح تجربة التعليم المهني
NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI=true

# عرض مدخل تصدير الفيديو
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true

# عرض مدخل استيراد PPTX التجريبي
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true

تعمل محادثة الصف دائمًا على Pi، وتتطلب نموذجًا يدعم استدعاء الأدوات/الدوال: اختر نموذجًا كهذا لـ الصف في إعداد نماذج المقرر، أو اضبط الخانة classroom (أو الخانة llm التي ترث منها) في openmaic.yml. مع Gemini استخدم الإعداد المسبق الأصلي google بدلًا من عنوان Google المتوافق مع OpenAI. يُرفض النموذج الذي يذكر الكتالوج المدمج أنه لا يدعم استدعاء الأدوات قبل الاستدلال، مع 400 MODEL_CONFIG_INVALID (reason: tool_calling_required)، ويشير إعداد نماذج المقرر إلى هذا الشرط عند خانة الصف.

أُزيلت محادثة الصف القديمة المبنية على director-graph، ولم يعد NEXT_PUBLIC_PI_CHAT_ENABLED يختار مسار المحادثة. احذف هذا المتغير من إعداداتك: لا يقبل بناء production إلا ترك المتغير غير مضبوط، أو سلسلة فارغة، أو true، أو 1 (مطابقة تامة)، ويفشل مع إرشادات الانتقال عند أي قيمة أخرى. في Docker Compose، يوفّر env_file الخاص بالخدمة (.env.local) قيم وقت التشغيل، لا وسائط البناء.

استيراد PPTX لا يزال تجريبيًا، والنتائج لم تُدمج بالكامل في تدفق بيانات الصف. جميع متغيرات NEXT_PUBLIC_* هي مفاتيح بناء؛ عند نشر Docker، يجب تمريرها كـ build args، لا يمكن فقط تعيينها في بيئة الحاوية.

خيارات خادم أخرى

يمكن تكوين الخيارات التالية عبر متغيرات البيئة:

bash
# توليد متوازي لمحتوى المشاهد؛ 0 أو عدم التعيين يعني توليد تسلسلي
PARALLEL_SCENE_CONCURRENCY=3

# السماح بالوصول إلى localhost والشبكات المحلية؛ فقط للنشر الذاتي/الشبكات الداخلية
ALLOW_LOCAL_NETWORKS=true

# خدمة تصيير MP4 اختيارية
RENDER_SERVICE_URL=http://render-service:9000

# إعدادات السجلات والتفكير
LOG_LEVEL=info
LOG_FORMAT=pretty
LLM_THINKING_DISABLED=false

متغيرات بيئة المزودين القديمة

هذه المتغيرات وserver-providers.yml هي إعدادات الإصدارات السابقة. ما زالت تعمل عند عدم وجود openmaic.yml (راجع الانتقال من الإعدادات القديمة)، لكنها مهملة وستُزال بعد إصدارين فرعيين من صدور إعدادات النماذج. مع openmaic.yml استخدمها فقط مصدرًا لإشارات ${VAR}. يشرح قسم الانتقال DEFAULT_MODEL وMODEL_FALLBACK؛ ولم يعد MODEL_ROUTES يُقرأ.

موفرو LLM

عادةً ما يستخدم موفرو السحابة ثلاثة متغيرات بيئة: مفتاح API، عنوان URL الأساسي، وقائمة النماذج. مفتاح API عادةً مطلوب، أما العنوان الأساسي وقائمة النماذج فاختياريان. Azure OpenAI يحتاج إلى تكوين نقطة نهاية المورد، بينما Ollama وLemonade لا يحتاجان إلى مفتاح API.

bash
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=                # تجاوز اختياري للعنوان الأساسي
OPENAI_MODELS=                  # قائمة بيضاء اختيارية للنماذج (مفصولة بفواصل)

بادئات الموفرين المدعومة:

البادئة الموفر
OPENAI_ OpenAI
AZURE_OPENAI_ Azure OpenAI
ANTHROPIC_ Anthropic
GOOGLE_ Google Gemini
DEEPSEEK_ DeepSeek
QWEN_ Alibaba Qwen
KIMI_ Moonshot Kimi
MINIMAX_ MiniMax (يستخدم نقطة نهاية متوافقة مع Anthropic افتراضيًا)
GLM_ Zhipu GLM
SILICONFLOW_ SiliconFlow
DOUBAO_ Doubao (ByteDance)
OPENROUTER_ OpenRouter
GROK_ xAI Grok
TENCENT_ Tencent Hunyuan
TENCENT_HUNYUAN_ Tencent Hunyuan (اسم بديل)
XIAOMI_ Xiaomi MiMo
MIMO_ Xiaomi MiMo (اسم بديل)
OLLAMA_ Ollama (محلي)
LEMONADE_ Lemonade (محلي)

يستخدم Azure OpenAI اسم النشر كمعرف للنموذج:

bash
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=your-deployment-name

للاتصال بخدمة LLM أخرى متوافقة مع OpenAI، أعلن في openmaic.yml مزودًا من نوع openai-compatible مع baseUrl وmodels الخاصة به، أو أضفه عبر إضافة مزود ضمن الإعدادات ← خدمات النماذج ← نماذج اللغة.

للتفاعل مع Gemini في الصف، استخدم الإعداد المسبق الأصلي google: أضف Google في الإعدادات ← خدمات النماذج ثم اختره لـ الصف في إعداد نماذج المقرر. يرفض Pi مبكرًا عنوان Google الرسمي المتوافق مع OpenAI (https://generativelanguage.googleapis.com/v1beta/openai) ويعرض إرشادات الانتقال؛ تُتابَع مسألة استدعاء الأدوات عبر المسار المتوافق في #1556. يتعرف هذا الفحص على ذلك العنوان الرسمي فقط ولا يحدد المزود خلف بوابات الجهات الخارجية.

النماذج المحلية (Ollama و Lemonade)

لا تحتاج Ollama وLemonade إلى مفتاح API؛ يجب كتابة عنوان URL الأساسي للخدمة المحلية في إعدادات الخادم لتجاوز فحص SSRF:

bash
OLLAMA_BASE_URL=http://localhost:11434/v1
# LEMONADE_BASE_URL=http://localhost:13305/v1

يمكن تحديد قائمة بيضاء للنماذج المتاحة باستخدام OLLAMA_MODELS أو LEMONADE_MODELS.

موفرو TTS

يستخدم موفرو TTS على الخادم متغيرات TTS__API_KEY، مع خيار TTS__BASE_URL للتجاوز.

bash
# Doubao TTS (Volcengine Seed-TTS، MP3 أصلي)
TTS_DOUBAO_API_KEY=appId:accessKey
TTS_DOUBAO_BASE_URL=       # تجاوز اختياري

# Qwen TTS
TTS_QWEN_API_KEY=
TTS_QWEN_BASE_URL=         # تجاوز اختياري

# أي نقطة نهاية متوافقة مع OpenAI TTS
TTS_OPENAI_API_KEY=
TTS_OPENAI_BASE_URL=

# VoxCPM2 (TTS ذاتي الاستضافة مع استنساخ الصوت، راجع صفحة VoxCPM2)
TTS_VOXCPM_BASE_URL=http://localhost:8000

بادئات TTS المدعومة تشمل TTS_OPENAI_، TTS_AZURE_، TTS_GLM_، TTS_QWEN_، TTS_MINIMAX_، TTS_DOUBAO_، TTS_ELEVENLABS_، TTS_GOOGLE_، TTS_VOXCPM_ و TTS_LEMONADE_. TTS المحلي لـ Lemonade وVoxCPM2 لا يحتاجان إلى مفتاح API. TTS المدمج في المتصفح لا يحتاج إلى إعدادات خادم.

يمكن للمسؤولين تعطيل موفر TTS معين على الخادم باستخدام TTS__ENABLED=false. لمزيد من التفاصيل عن VoxCPM2 (TTS ذاتي الاستضافة واستنساخ الصوت)، راجع الفصل الخاص بـ VoxCPM2.

لاستخدام عنوان TTS آخر متوافق مع OpenAI، أعلن في openmaic.yml مزودًا من نوع openai-tts بذلك baseUrl وعيّن له خانة tts. لا يمكن لمساحات العمل إضافة عناوين TTS مخصصة في إعدادات النماذج.

ASR (تحويل الكلام إلى نص)

bash
# OpenAI Whisper
ASR_OPENAI_API_KEY=
ASR_OPENAI_BASE_URL=       # تجاوز اختياري

# Qwen ASR
ASR_QWEN_API_KEY=
ASR_QWEN_BASE_URL=         # تجاوز اختياري

# Azure ASR
ASR_AZURE_API_KEY=
ASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com

# FunASR (محلي، لا يحتاج إلى مفتاح)
ASR_FUNASR_BASE_URL=http://localhost:8000/v1

# Lemonade ASR (محلي، لا يحتاج إلى مفتاح)
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1

شغّل خدمة FunASR المتوافقة مع OpenAI باستخدام funasr-server --device cuda. تستقبل ملفات WAV على /v1/audio/transcriptions وتدعم SenseVoiceSmall وParaformer وFun-ASR-Nano.

ASR المدمج في المتصفح لا يحتاج إلى إعدادات خادم.

لاستخدام عنوان ASR آخر متوافق مع OpenAI، أعلنه في openmaic.yml بالإعداد المسبق openai-whisper وbaseUrl الخاص به، وعيّن له خانة asr. لا يمكن لمساحات العمل إضافة عناوين ASR مخصصة في إعدادات النماذج:

yaml title="openmaic.yml"
providers:
  my-asr:
    preset: openai-whisper
    apiKey: ${MY_ASR_API_KEY}
    baseUrl: https://asr.example.com/v1

slots:
  asr: my-asr:whisper-1

موفرو توليد الصور

يستخدم موفرو الصور متغيرات IMAGE__API_KEY، مع خيار IMAGE__BASE_URL. البادئات المدعومة تشمل:

IMAGE_OPENAI_، IMAGE_SEEDREAM_، IMAGE_QWEN_IMAGE_، IMAGE_NANO_BANANA_، IMAGE_MINIMAX_، IMAGE_GROK_، IMAGE_GLM_ و IMAGE_LEMONADE_.

يولّد GLM Image (Zhipu CogView) الصور بنماذج CogView (cogview-3-flash وcogview-4):

yaml title="openmaic.yml"
providers:
  glm-image-service:
    preset: glm-image
    apiKey: ${IMAGE_GLM_API_KEY}
    # اختياري:
    baseUrl: https://open.bigmodel.cn/api/paas/v4

slots:
  image: glm-image-service:cogview-3-flash

أو اضبطه عبر متغيرات البيئة:

bash
IMAGE_GLM_API_KEY=
# اختياري:
IMAGE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

Lemonade خدمة محلية ولا تحتاج إلى مفتاح API:

bash
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1

لا يحتاج ComfyUI Image إلى مفتاح API. يعمل على شبكتك الخاصة، لذا يضبطه النشر: أعلنه في openmaic.yml بعنوان خادم ComfyUI وعيّن له خانة image.

yaml title="openmaic.yml"
providers:
  comfyui:
    preset: comfyui-image
    baseUrl: http://host.docker.internal:8188

slots:
  image: comfyui

ضع ملفات JSON لسير العمل المصدّرة بصيغة API في مجلد public/ الخاص بـ OpenMAIC. يجب أن تطابق أسماء الملفات comfyui-*.json أو تحتوي على workflow؛ يكتشف OpenMAIC هذه الملفات تلقائيًا ويعرضها كسير عمل. مع Docker، أضف سير العمل قبل بناء الصورة أو اربط ملف سير عمل منفردًا في /app/public/ داخل الحاوية. بما أن المزود مُعلن من النشر، فلا يحتاج خادم ComfyUI على المضيف أو على شبكة خاصة إلى ALLOW_LOCAL_NETWORKS.

موفرو توليد الفيديو

يستخدم موفرو الفيديو متغيرات VIDEO__API_KEY، مع خيار VIDEO__BASE_URL. البادئات المدعومة تشمل:

VIDEO_SEEDANCE_، VIDEO_KLING_، VIDEO_SORA_، VIDEO_MINIMAX_، VIDEO_GROK_ و VIDEO_HAPPYHORSE_.

تحليل المستندات والوسائط

تعتمد صيغ المواد التعليمية على المحلل المختار. حاليًا يدعم النصوص، PDF، مستندات Office، الصور، وبعض صيغ الصوت والفيديو؛ تختلف الصيغ المدعومة والقدرات حسب الموفر.

bash
# MinerU ذاتي الاستضافة
PDF_MINERU_BASE_URL=http://localhost:8888

# MinerU backend اختياري ذاتي الاستضافة
PDF_MINERU_BACKEND=pipeline

# MinerU Cloud
PDF_MINERU_CLOUD_API_KEY=
PDF_MINERU_CLOUD_BASE_URL=https://mineru.net/api/v4

# AliDocMind (يستخدم AccessKey من Alibaba Cloud بدلاً من مفتاح API منفصل)
ALIDOCMIND_ACCESS_KEY_ID=
ALIDOCMIND_ACCESS_KEY_SECRET=
ALIDOCMIND_BASE_URL=       # تجاوز اختياري

unpdf مدمج في OpenMAIC ويستخدم لتحليل PDF الأساسي. لتحليل مستندات Office، الصور، أو المواد التي تحتاج إلى OCR، جداول، معادلات أو تحليل تخطيط، يجب اختيار موفر MinerU أو AliDocMind المتوافق.

يدعم AliDocMind تحليل PDF، DOCX، PPTX، XLSX، بالإضافة إلى PNG، JPG/JPEG، BMP، GIF. حاليًا، يدعم تحليل الوسائط الصوتية والفيديو فقط عبر AliDocMind: صيغ الفيديو MP4، MOV، AVI، MKV، WMV، وصيغ الصوت MP3، WAV، AAC؛ لا يدعم M4A.

البحث عبر الإنترنت

اضبط موفري البحث Tavily، Exa، Firecrawl، Bocha، Brave، Baidu، SearXNG أو MiniMax:

bash
TAVILY_API_KEY=
TAVILY_BASE_URL=           # تجاوز اختياري

EXA_API_KEY=
EXA_BASE_URL=https://api.exa.ai  # تجاوز اختياري

FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev  # تجاوز اختياري

BOCHA_API_KEY=
BOCHA_BASE_URL=            # تجاوز اختياري

BAIDU_API_KEY=
BAIDU_BASE_URL=https://qianfan.baidubce.com  # تجاوز اختياري

# SearXNG ذاتي الاستضافة، لا يحتاج إلى مفتاح API
SEARXNG_BASE_URL=

WEB_SEARCH_MINIMAX_API_KEY=
WEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com  # تجاوز اختياري

لا يحتاج Brave وSearXNG إلى مفتاح API. لا يوجد مفتاح للبحث على الويب في كل عملية توليد: يعمل متى حُلت خانة webSearch لمساحة العمل، وإيقافه (webSearch: null في openmaic.yml، أو بمفتاح البحث في الإعدادات ← إعدادات نماذج الدورة) يؤثر في عمليات التوليد اللاحقة لتلك المساحة. يُقدَّم البحث على الويب في Grok عبر أداة البحث في Grok LLM، وليس مزود بحث مستقلًا.

server-providers.yml

مهمل لصالح openmaic.yml. عند وجود الملفين معًا، يضبط openmaic.yml وحده النماذج.

بدلاً من متغيرات البيئة، يمكنك وضع إعدادات موفري الخادم المسجلين في الكود داخل ملف server-providers.yml في جذر المشروع. يُحمّل الملف عند بدء الخادم، والبنية تطابق تصنيفات متغيرات البيئة:

yaml title="server-providers.yml"
providers:
  openai:
    apiKey: sk-...
    baseUrl: https://api.openai.com/v1
    models:
      - gpt-5.5
  anthropic:
    apiKey: sk-ant-...

tts:
  doubao-tts:
    apiKey: appId:accessKey

asr:
  openai-whisper:
    apiKey: sk-...

pdf:
  mineru:
    baseUrl: http://localhost:8888
  alidocmind:
    accessKeyId: your-access-key-id
    accessKeySecret: your-access-key-secret

image:
  seedream:
    apiKey: ...

video:
  seedance:
    apiKey: ...

web-search:
  tavily:
    apiKey: tvly-...
  exa:
    apiKey: ...
  firecrawl:
    apiKey: fc-...
  searxng:
    baseUrl: http://localhost:8080

تتجاوز متغيرات البيئة حقول YAML المطابقة حقلًا بحقل. يجب أن تستخدم المفاتيح معرفات الموفرين المسجلين في الكود مثل openai، doubao-tts، openai-whisper، mineru، alidocmind، seedream، seedance، exa، tavily أو searxng. لا يمكن تسجيل معرفات غير معروفة هنا كموفري LLM مخصصين.

تفويض النماذج لدى المزودين المدارين من الخادم

للمزودين الذين يرسلون النموذج المختار إلى الخدمة الأصلية باستخدام بيانات اعتماد النشر، اضبط قائمة models صريحة في openmaic.yml (providers..models)، أو متغير البيئة *_MODELS المقابل / قائمة models في قسم server-providers.yml. ينطبق ذلك على المحادثة والصور والفيديو وتحويل النص إلى كلام والتعرف على الكلام والبحث على الويب المعتمد على النماذج. تُطابَق معرّفات النماذج مطابقة تامة، مع مراعاة حالة الأحرف، بعد إزالة المسافات المحيطة عند تحميل الإعدادات. تُفوَّض الأسماء المستعارة الحتمية للمحوّلات بمعرّف النموذج المُعلَن. تُعيد طلبات النماذج غير المدرجة HTTP 403 مع PROVIDER_DISABLED والرسالة «This model is not authorized for server-managed credentials».

تغيير كاسر: من دون قائمة صريحة، لا يمكن استخدام إلا النماذج الافتراضية والاحتياطية للنشر، والنماذج الأساسية والاحتياطية التي تشير إليها خانات تلك القدرة. في الإعدادات القديمة، يفوّض DEFAULT_MODEL وMODEL_FALLBACK نماذج المحادثة. خانة الوسائط التي تذكر المزود فقط تفوّض النموذج الافتراضي الوحيد للمحوّل، ولا تفوّض كتالوج الإعداد المسبق كله. لا يمكن لتعيينات مساحة العمل توسيع المجموعة المفوَّضة في النشر. ويجب أن يفوّض تسجيل الأصوات وتوليفها أيضًا أي نموذج يُختار لتوافق الصوت.

للانتقال، اذكر كل نموذج ينبغي أن يتمكن المستخدمون من اختياره، وأعد تشغيل الخادم، وحدّث اختيارات مساحات العمل التي لم تعد مفوَّضة. تذكر سجلات التشغيل الإعداد الواجب إضافته لكل مزود لديه بيانات اعتماد بلا قائمة. لا تتأثر بيانات اعتماد المستخدمين الخاصة بمزوديهم، ولا المزودون المحليون الذين لا يحتاجون إلى مفتاح مثل Ollama وLemonade. المزود المضبوط دون بيانات اعتماد غير مقيَّد، فلا توجّهه إلى بوابة تضيف بيانات اعتماد خاصة بها نحو الخدمة الأصلية (مثل وكيل داخلي متوافق مع OpenAI): أعطِ تلك البوابة مفتاحًا واضبطها مزودًا ببيانات اعتماد وقائمة models، أو قيّد النماذج في البوابة نفسها. لا تحتاج محللات المستندات وخدمات البحث التي لا تختار نموذجًا (مثل Tavily) إلى قائمة نماذج.

yaml
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
    models: [gpt-5.4-mini]
slots:
  llm: openai:gpt-5.4-mini

المكافئ في الإعدادات القديمة: OPENAI_API_KEY=... وOPENAI_MODELS=gpt-5.4-mini. للوسائط استخدم بادئة القدرة، مثل IMAGE_SEEDREAM_MODELS وVIDEO_SEEDANCE_MODELS وTTS_OPENAI_MODELS وASR_OPENAI_MODELS وWEB_SEARCH_CLAUDE_MODELS.

البحث وجلب الصفحات باستخدام DeepSeek

يشترك الإعداد deepseek في DEEPSEEK_API_KEY وعنوان API الأساسي بين المحادثة والبحث واستخراج Markdown المستضاف. يستخدم البحث POST /v1/tools/search ويعيد حتى 50 نتيجة، ويقتطع الاستعلام إلى 128 نقطة ترميز Unicode. يستخدم summary ثم snippet عند فراغه. يستخدم الجلب POST /v1/tools/fetch ويقرأ content وtitle الاختياري غير الفارغ.

في الإعدادات القديمة، فعّل WEB_SEARCH_DEEPSEEK_ENABLED=true أو أضف web-search.deepseek صراحةً إلى server-providers.yml. بيانات اعتماد المحادثة وحدها لا تفعّل البحث ولا تستبدل مزود البحث الافتراضي الحالي. يعطّل WEB_SEARCH_DEEPSEEK_ENABLED=false البحث قسرًا. عند وجود openmaic.yml لا يستطيع المتغير القديم تعيين خانة؛ أعلن المزود وعيّن webSearch و/أو webFetch صراحةً.

The default API base is https://api.deepseek.com/v1. On api.deepseek.com, every base path (including /beta) is normalized to /v1 before appending /tools/search or /tools/fetch. Custom gateway bases preserve their configured path prefix; a bare root gets /v1 first. Custom endpoints belong in deployment configuration, while client and workspace overrides remain restricted to official endpoints. The default fetch reader remains builtin. See DeepSeek official pricing for current request charges. Failed requests and searches with no results are not billed.

yaml
providers:
  deepseek:
    preset: deepseek
    apiKey: ${DEEPSEEK_API_KEY}
    baseUrl: https://api.deepseek.com/v1
slots:
  webSearch: deepseek
  webFetch: deepseek

محادثة Kimi والبحث على الويب

أعلن حساب Kimi واحدًا وعيّن له الخانتين:

yaml
providers:
  kimi:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    # اختياري: https://api.moonshot.ai/v1 للمنصة المفتوحة الدولية.
    # baseUrl: https://api.moonshot.ai/v1
slots:
  llm: kimi:kimi-k3
  webSearch: kimi

لمفتاح Coding Plan، استخدم preset: kimi-coding-plan وllm: kimi:kimi-for-coding؛ عنوانه الأساسي الافتراضي هو https://api.kimi.com/coding/v1. يجب إقران مفاتيح المنصة المفتوحة وCoding Plan بالعناوين الأساسية المطابقة لها. في الإعدادات القديمة، لا يوفّر KIMI_API_KEY وKIMI_BASE_URL الاختياري بيانات اعتماد البحث إلا بعد التفعيل الصريح: اضبط WEB_SEARCH_KIMI_ENABLED=true أو أضف web-search.kimi في server-providers.yml (مثلًا مع enabled: true). لا تفعّل بيانات اعتماد المحادثة وحدها البحث ولا تغيّر مزود البحث الافتراضي. يعطّل WEB_SEARCH_KIMI_ENABLED=false بحث Kimi حتى عند تعيين خانة له. WEB_SEARCH_KIMI_ENABLED هو المفتاح الوحيد لبحث Kimi. ينطبق KIMI_MODELS على المحادثة فقط. عند تعارض حسابَي المحادثة والبحث في الإعدادات القديمة، يظهر تحذير ويُتخطّى إدخال البحث؛ أعلن معرّفات مزودين منفصلة في openmaic.yml عند الحاجة إلى حسابات منفصلة. عند وجود openmaic.yml، أعلن المزود وعيّن الخانات صراحةً. تتيح الإعدادات ومعالج التشغيل الأول استخدام القدرتين من الحساب نفسه.

يعتمد اختيار نقطة النهاية على المسار الأساسي: بعد إزالة الشرطات المائلة الختامية، يستخدم المسار المنتهي بـ /coding/v1 نقطة /search، وتستخدم المسارات الأخرى /tools/search_pro. يجب أن تحافظ الوكلاء المخصصة المضبوطة في النشر على بنية المسار هذه. يوصي المعالج بالبحث لعناوين الحسابات الرسمية فقط، لذلك لا يحصل وكيل المحادثة المخصص لمساحة العمل على تعيين بحث تلقائيًا.

عند وجود openmaic.yml، أعلن providers.: { preset: kimi } وعيّن slots.webSearch: ؛ لا يستطيع المتغير القديم WEB_SEARCH_KIMI_ENABLED تعيين هذه الخانة.

خانة مزوّد جلب صفحات الويب

تحدد webFetch خدمة القراءة التي تستخدمها أداة الوكيل fetch_url. الخدمة الافتراضية هي builtin في الإعدادات القديمة والفارغة وملفات YAML، حتى عند عدم تحديد هذه الخانة تحت lock: all. يؤدي ضبط webFetch: null صراحةً إلى تعطيل الجلب وإزالة الأداة وتعليماتها من عمليات التشغيل اللاحقة. يستخدم كل طلب مزوّدًا واحدًا دون مزوّد احتياطي أو تصعيد إلى خدمة أخرى. تؤدي الإشارة غير الصالحة إلى مزوّد أو عنوان خدمة في مساحة العمل ترفضه السياسة إلى فشل تشغيل الوكيل بالكامل أثناء تحديد الإمكانات، قبل بدء الوكيل، كما في webSearch. هذا خطأ في الإعدادات وليس نتيجة فشل من fetch_url، ولا يتم الانتقال إلى القارئ المدمج.

المزوّد بيانات الاعتماد الاستخراج
builtin غير مطلوبة قارئ Readability/Turndown محلي؛ تستخدم ملفات PDF خانة document
kimi مفتاح المزوّد Kimi الحالي وعنوانه الأساسي خدمة مستضافة لاستخراج Markdown، تشمل روابط PDF
deepseek مفتاح محادثة DeepSeek وعنوان API الأساسي استخراج Markdown المستضاف
firecrawl مفتاح Firecrawl API (preset: firecrawl) خدمة مستضافة لاستخراج المحتوى الرئيسي للصفحة بصيغة Markdown، تشمل روابط PDF
yaml
providers:
  reader:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    baseUrl: https://api.moonshot.cn/v1
slots:
  webFetch: reader

يوفر كلا الإعدادين المسبقين kimi وkimi-coding-plan قدرة webFetch. لبيانات اعتماد Coding Plan، استخدم preset: kimi-coding-plan؛ وعنوانه الأساسي الافتراضي هو https://api.kimi.com/coding/v1. يُحافظ على أي عنوان أساسي مضبوط صراحةً. تستخدم عناوين Moonshot Open Platform (https://api.moonshot.cn/v1 وhttps://api.moonshot.ai/v1) المسار /tools/fetch، بينما يستخدم Coding Plan المسار /fetch. يرسل كلاهما طلب POST يحتوي على { "url": ... } مع مصادقة Bearer. يقبل المهايئ markdown أو content. اختبر أحد المشرفين POST https://api.kimi.com/coding/v1/fetch باستخدام مفتاح فعلي وأكد الاستجابة { markdown, title, url }؛ لا يزال توافر العناوين الدولية غير متحقق منه. تراعي الطلبات إعداد proxy الخاص بالمزوّد ومتغيرات بيئة الوكيل القياسية.

يوفر DeepSeek أيضًا استخراج Markdown المستضاف عبر الإعداد deepseek مع إعادة استخدام مفتاح المحادثة وعنوان API الأساسي. عيّن webFetch صراحةً كما في المثال أعلاه. يستخدم /v1/tools/fetch ويحفظ عنوان البوابة المضبوط في النشر. المهل الزمنية وإعادة المحاولة مرة واحدة لأخطاء النقل فقط مماثلة لـ Kimi، دون إعادة أخطاء HTTP أو الاستجابات غير الصالحة.

استخدم بيانات الاعتماد الحالية في الإعدادات ← خدمات النماذج ← جلب صفحات الويب، واختر القارئ في إعداد نماذج المقرر ← جلب صفحات الويب. يُستخدم المفتاح نفسه للدردشة مع هذا المزوّد؛ تغييره أو حذفه يؤثر في الدردشة أيضًا. تكون البطاقة للقراءة فقط عندما يثبّتها المسؤول. يحتفظ الإعداد الأولي بالقارئ المدمج حتى اختيار قارئ آخر صراحةً. يمكن لبيانات اعتماد مساحة العمل استخدام عناوين API الأساسية الرسمية؛ أما عناوين الجلب المخصصة والوكلاء فيجب أن يعلنها المسؤول في openmaic.yml.

بخلاف kimi، يستخدم firecrawl مفتاحًا خاصًا به: عرّف مزوّدًا بالإعداد preset: firecrawl والمفتاح apiKey (مثل ${FIRECRAWL_API_KEY})، أو أدخل المفتاح في الإعدادات ← خدمات النماذج ← جلب صفحات الويب. يرسل الطلب POST /v2/scrape بمصادقة Bearer ويطلب المحتوى الرئيسي للصفحة بصيغة Markdown. العنوان الأساسي الافتراضي هو https://api.firecrawl.dev؛ ويجب أن يعرّف المسؤول العنوان الأساسي لخادم Firecrawl المستضاف ذاتيًا في openmaic.yml.

يخدم مزوّد firecrawl نفسه البحث على الويب أيضًا (POST /v2/search): مفتاح واحد وعنوان أساسي واحد يكفيان للأمرين، لذا يمكن تعيين مزوّد واحد في slots.webSearch وslots.webFetch معًا. تضبط المتغيرات القديمة FIRECRAWL_API_KEY وFIRECRAWL_BASE_URL هذا المزوّد نفسه، ويبقى جلب صفحات الويب على builtin إلى أن يُعيَّن webFetch له.

قبل إرسال رابط إلى خدمة مستضافة، تُطبق بوابة روابط الجلسة والتطبيع الصارم للرابط وفحص جميع عناوين DNS المحلية. تُطبق الفحوص نفسها على الرابط النهائي الذي تعيده الخدمة قبل قبول المحتوى. تتحكم الخدمات المستضافة في DNS والتحويلات الوسيطة الخاصة بها، لذلك لا تستطيع الفحوص المحلية تثبيت اتصالاتها الشبكية. يظل النص بيانات غير موثوقة، ويخضع لاكتشاف صفحات التحقق وحدود الحجم بالبايت وعلامة الاقتطاع، مع حفظ مواد الويب وصيغة النتيجة {trusted, untrusted} كما في السابق.


Packages Docs Content Docs Configuration.Ja (`packages/docs/content/docs/configuration.ja.mdx`)


title: 設定

OpenMAIC には 3 つの設定ソースがあり、それぞれが 1 つの役割だけを担います。

ソース 書く人 内容 反映タイミング
openmaic.yml 運用者 プロバイダー、サーバーの既定の割り当て、ロックするスロット、ユーザーが独自のキーを追加できるか 起動時(変更後は再起動が必要)
Web アプリのモデル設定 ワークスペースのユーザー ユーザー自身のプロバイダーと、openmaic.yml がロックしていないスロットの割り当て。データベースに保存 即時
環境変数(.env.local、コンテナの Secret) 運用者 openmaic.yml が参照するシークレットと、その他すべてのサーバーオプション 起動時

DATABASE_URL is required; other environment variables are optional (see Deployment). Providers that use server credentials for model selection also need authorized model lists, as described below. See .env.example for environment variables, openmaic.example.yml for a sample model configuration, and Supported models for preset and model IDs.

以前のリリースでは、モデルはプロバイダー環境変数、server-providers.yml、DEFAULT_MODEL、MODEL_ROUTES でしか設定できませんでした。MODEL_ROUTES を除き、これらは現在も動作しますが非推奨です。旧設定からの移行を参照してください。

モデルとプロバイダー

基本概念

  • プロバイダー(provider):サーバーが呼び出せる 1 つのアカウント。たとえば OpenAI のキー、MiniMax の Token Plan、セルフホストの OpenAI 互換サーバーなど。各プロバイダーには自分で決める ID と、どのベンダーでどの機能をカバーするかを示すプリセット(preset)があります。
  • スロット(slot):製品の中で AI を使う箇所。たとえばコースのアウトライン、スライドの内容、音声合成、Web 検索など。スロットは機能ごとに 1 本の木を構成し、割り当てのないスロットは親スロットに従います。
  • 割り当て(assignment):スロットが使うプロバイダーとモデル(provider:model)。呼び出しパラメーターとフォールバックモデルを付けることもでき、null でスロットを無効にできます。

機能が使えるかどうかは、そのスロットが割り当てに解決されるかどうかだけで決まります。別のオン/オフスイッチはありません。

openmaic.yml

サーバーは起動ディレクトリ(リポジトリのルート。Docker イメージでは /app)から openmaic.yml を読み込みます。別のパスを使うには OPENMAIC_CONFIG を設定します。相対パスは同じディレクトリを基準に解決され、この場合ファイルは必ず存在しなければなりません。ファイルがない場合、サーバーは Web アプリの設定と、まだ設定されている旧来の変数を使います。

ファイルは起動時に一度だけ読み込まれるため、変更後はサーバーを再起動してください。サーバーは起動前にファイル全体を検証します。未設定の ${VAR}、不明なプリセットやスロット、宣言されていないプロバイダーへの参照、スロットの機能を提供しないプロバイダーがあるとサーバーは起動せず、問題ごとにその場所が表示されます。

text
[boot] Invalid server configuration; the server will not start: Invalid model configuration in /app/openmaic.yml:
  - providers.openai.apiKey: environment variable OPENAI_API_KEY is not set
  - slots.course.outlines: unknown slot

シークレットはファイルに直接書かず、${VAR} で参照して環境変数に設定してください。環境変数から来た値はこれらのメッセージに表示されません。

完全な例

yaml title="openmaic.yml"
providers:
  # <プロバイダー ID>:小文字、数字、ハイフン
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}      # 起動時に環境変数から読み込む
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  openai-image:                    # 同じキーを画像生成に使う
    preset: openai-image
    apiKey: ${OPENAI_API_KEY}
  minimax:                         # Token Plan:チャット、画像、動画、TTS、検索
    preset: minimax
    apiKey: ${MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}
  local:                           # 任意の OpenAI 互換サーバー
    preset: openai-compatible
    baseUrl: http://ollama:11434/v1
    models: [qwen3:8b]

slots:
  llm: openai:gpt-5.5              # 以下のすべてのチャットスロットの既定値
  course.outline:
    model: anthropic:claude-sonnet-4-6
    fallback: openai:gpt-5.4-mini  # 再試行可能な失敗時に一度だけ使う
  course.content.slide:
    model: minimax:MiniMax-M3
    thinking: { enabled: false }
    fallback: minimax:MiniMax-M2.7
  classroom: local:qwen3:8b        # モデル ID にコロンを含めてよい
  agent:
    model: openai:gpt-5.5          # ツール呼び出しが必要
    api: openai-completions
  tts: minimax:speech-2.8-turbo
  image: openai-image:gpt-image-2
  webSearch: tavily                # プロバイダーのみ:その既定モデル
  video: null                      # 既定では無効。ユーザーが有効にできる

lock: [tts]                        # サブツリーごと全員に対して固定
allowUserKeys: false               # ユーザーはこれらのプロバイダーから選ぶだけで、追加はできない

このファイルでは、slots に書いたスロットはサーバーの既定値です。ユーザーはモデル設定でそれぞれ変更できますが、lock で固定した tts だけは全員に対して変更できません。記載していないチャットスロット(例:course.agents)は親(llm)に従い、記載していない機能のルート(例:asr や document)はユーザーが割り当てるまで未設定のままです。allowUserKeys: false の場合、ユーザーはここで宣言したプロバイダーの中からのみ選びます。

プロバイダー

providers の各エントリが 1 つのプロバイダーを宣言します。キーがその ID で、スロットからの参照に使います。1〜63 文字の小文字・数字・ハイフンで、先頭は英字か数字です。

フィールド 必須 意味
preset はい 使用する組み込みプリセット。対応モデル → プリセットを参照。
apiKey 通常は必要 API キー。キー不要のサービス(ローカルサーバー、Brave、SearXNG、unpdf)では省略します。
baseUrl 一部のプリセット プリセット既定のエンドポイントの代わりに呼ぶ URL。openai-compatible、azure、searxng、mineru では必須です。
models For server-managed credentials Authorized model ids. Deployment slot defaults/fallbacks for the capability are also authorized; catalogues and workspace assignments do not grant additional models.
proxy いいえ このプロバイダーへのリクエストに使う HTTP プロキシの URL。
credentials 一部のプリセット 単一のキーを使わないサービス用のキーペア。AliDocMind は accessKeyId と accessKeySecret を使います。
options いいえ 秘密でない、プロバイダー固有の設定。たとえば voxcpm-tts の backend。名前は英数字、_、- で、値は文字列・数値・真偽値(ネストしたマップやリストは不可)です。${VAR} も使えます。オプションはモデル設定に表示されるため、キーを入れてはいけません。デプロイ専用で、モデル設定からは設定できません。

ほとんどのプリセットは組み込みレジストリに対応し、1 つの機能だけをカバーします。openai プリセットはチャットのみで、OpenAI の画像・音声合成・文字起こしはそれぞれ openai-image、openai-tts、openai-whisper プリセットです。機能ごとにプロバイダーを 1 つ宣言し、1 つのキーで複数の機能を使う場合は同じ ${VAR} を参照してください。Token Plan プリセット(minimax、tokendance、volcengine-ark、kimi-coding-plan)は 1 つのキーで複数の機能をカバーし、各機能はプラン独自のエンドポイントを使います。プロバイダーに設定した baseUrl は、そのプロバイダーがカバーするすべての機能のエンドポイントを置き換えるため、Token Plan に baseUrl を設定するのは全機能が一緒に移る場合だけにしてください。

Amazon Bedrock(preset: bedrock)はサーバーの AWS 認証情報チェーン(環境変数、プロファイル、ロール)でリクエストに署名し、リージョンは BEDROCK_REGION、AWS_REGION、AWS_DEFAULT_REGION から読み取ります。apiKey は任意のベアラートークンです。

スロット

スロット 機能 親 用途
llm chat — 既定のチャットモデル。以下のどのスロットにも該当しないチャット呼び出しすべて
course.research chat llm コース調査時の Web 検索クエリの書き換え
course.outline chat llm シーンのアウトライン
course.agents chat llm 教師とクラスメイトのエージェントのプロフィール
course.content chat llm シーンの内容(専用スロットのないシーンタイプ)
course.content.slide chat course.content スライドシーン
course.content.quiz chat course.content クイズシーン
course.content.interactive chat course.content インタラクティブシーン
course.content.pbl chat course.content プロジェクト型学習(PBL)シーン
course.actions chat llm シーンのアクション(各シーンでエージェントが話す内容と行う動作)
classroom chat llm 授業中のチャット、クイズの採点、PBL ランタイム
agent chat llm Pro ワークベンチのエージェント。ツール呼び出しが必要。 api と contextWindow も指定可能
agent.title chat agent Pro セッションのタイトル。openmaic.yml でのみ設定でき、モデル設定には表示されません
tts tts — 音声合成
asr asr — 音声認識(音声入力、音声・動画の文字起こし)
image image — 画像生成
video video — 動画生成
webFetch webFetch — Webページ抽出(既定:builtin)
webSearch webSearch — Web 検索
document document — ドキュメント解析サービス(MinerU、MinerU Cloud、AliDocMind)。組み込みの PDF テキスト抽出にはサービスは不要です

スロットの解決方法:

  • 呼び出し元のスロットから機能のルートへ向かって上にたどり、最初に見つかった割り当てが使われます。つまり親の割り当てがそのサブツリー全体の既定値になり、親を変えると自分の割り当てを持たない子もすべて変わります。
  • ロックされたサブツリーの中(そのスロットか祖先が lock に挙げられている、または lock: all)では openmaic.yml だけが参照されます。ロックされたスロットまで上にたどり、ファイルに書かれた最初の割り当てが使われます。それ以外ではユーザーの選択が優先されます。まずスロットからルートまでワークスペース自身の割り当てを探し、一つもない場合にだけ、同じくスロットから上へ openmaic.yml の既定値を探します。そのため、ワークスペースが親を設定していれば、子にサーバーの既定値が書かれていても、ワークスペースが変更していない子は親に従います。サーバーの値はあくまで既定値です。
  • null はそこで探索を止めます。そのスロットと、その下で自分の割り当てを持たないスロットはすべて無効になります。
  • ルートまで割り当てがなければ、その機能は未設定です。呼び出しはスロット名を示すエラーで失敗し、どのベンダーにもフォールバックしません。
  • 要件(requirement)はそれを宣言したスロットに属し、割り当てか継承かにかかわらず、そのスロットが解決したモデルに対してチェックされます。agent はツール呼び出しを必要とし、組み込みカタログがそのモデルは非対応だとしている場合、エージェントは妥協せずに実行を拒否します。カタログにないモデルは許可されます。agent.title には要件がないので、小さいモデルを使えます。

割り当て

割り当ての書き方は 4 通りです。

yaml
slots:
  llm: openai:gpt-5.5            # プロバイダー:モデル
  webSearch: tavily              # プロバイダーのみ:その既定モデル
  course.outline:                # パラメーター付きのオブジェクト
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
    thinking: { effort: low }
  video: null                    # 無効

オブジェクト形式で使えるフィールド:

フィールド 意味
model 必須。プロバイダー:モデル。チャット以外のスロットでは プロバイダー のみも可。
fallback 一度だけ再試行するモデル(同じ書式)。言語モデルのスロットのみ。フォールバックを参照。
thinking チャットモデルの推論パラメーター:enabled(true/false)、mode(default、disabled、enabled、auto)、effort(none、minimal、low、medium、high、xhigh、max)、level(minimal、low、medium、high)、budgetTokens、excludeReasoningOutput。各プロバイダーのアダプターは API が対応する部分だけを適用します。
api agent のみ:転送方式。openai-completions(既定)または openai-responses。
contextWindow agent のみ:コンテキスト圧縮で想定するウィンドウサイズ。カタログの値より小さくしたい場合に使います。

agent スロットでは thinking.effort を設定できません。ツール呼び出しの転送方式では推論強度と関数ツールを併用できないためです。

プロバイダーのみの参照

:model のない参照は、そのプロバイダーの既定モデルを意味します。Token Plan がその機能に推奨するモデル、なければ組み込みレジストリの既定モデルです。検索やドキュメントのサービスはほとんど選べるモデルがないため、webSearch: tavily や document: mineru-cloud と書くのが普通です。チャットスロットには必ずモデルを書き、llm: openai は起動時に拒否されます。

フォールバック

フォールバックは言語モデルのスロット、つまり llm とその下のすべてのスロットにだけ適用されます。tts、asr、image、video、webSearch、webFetch、document のスロットに書いた fallback は拒否されます(openmaic.yml では起動時、モデル設定では保存時)。

主モデルの呼び出しが自身の再試行の後も再試行可能な失敗で終わった場合、スロットの fallback で一度だけ再試行します。再試行可能な失敗とは、SDK が一時的と判断したエラー、タイムアウト、ネットワークエラー、レート制限(429)、容量不足(503)、または空の出力です。プロバイダーがクォータ切れを明示的に返した場合(insufficient_quota などの OpenAI 互換のクォータコード。HTTP ステータスは問いません)は、主モデルで再試行せず、すぐにフォールバックに切り替えます。コンテンツ安全性による拒否やその他の 4xx エラーではフォールバックしません。フォールバックは通常の呼び出しとストリーミング呼び出しの両方に適用され、スロットの thinking パラメーターを使います。

フォールバックは割り当ての一部であり、割り当てと一緒に継承されます。自分の割り当てを持たない course.content.slide は、course.content のモデルとフォールバックを使います。要件のあるスロットは、要件を満たさないフォールバックでは再試行しません。

機能を無効にする

そのスロットに null を書きます。video: null は既定で動画生成を無効にしますが、ユーザーはモデル設定で有効にできます。全員に対して無効のままにするには lock: [video] を加えます。course.content.pbl: null は PBL シーンの生成だけを無効にし、course.content の残りは通常どおり動きます。無効なスロットへの呼び出しは即座に失敗し、メディア系の API は別のベンダーを選ぶ代わりに 403 を返します。

既定値・ロックとモデル設定

openmaic.yml が slots に書いたものはサーバーの既定値です。ユーザーはモデル設定で変更でき、カードには サーバー既定 と表示され、サーバー既定に戻す で変更を取り消せます。lock に挙げたものは管理者が固定し、サブツリー全体とともに全員に対して固定されます。

yaml
lock: [llm, video]   # これらのスロットとその下のすべてのスロット
# lock: all          # すべてのスロット
  • lock に挙げるスロットはすべて slots に書かれている必要があります(null も含みます。video: null と lock: [video] で全員の動画が無効のままになります)。未知のスロットや、slots に書かれていないロック対象のスロットがあると、位置(lock.1: ...)を示してサーバーは起動しません。
  • ロックしたスロットは子も固定します。lock: [llm] では、どのユーザーも course.outline などの言語モデルスロットを設定できず、すべて openmaic.yml だけで解決されます。
  • lock: all は書かれているかどうかにかかわらずすべてのスロットを固定します。slots に書かれていない機能のルートは、全員に対して未設定になります。ただし未指定の webFetch は builtin を使用し、webFetch: null で無効にできます。
  • null、サーバーの既定値、ロックは組み合わせて使えます。video: null だけなら既定で無効でユーザーが有効にでき、lock: [video] を加えると無効のままです。

設定画面は、構成に応じて次の三つの形のいずれかになります。

形 条件 表示されるもの
自分で設定する allowUserKeys が有効(既定) Token Plan、モデルサービス、コースモデル設定
モデルを選ぶ allowUserKeys: false コースモデル設定のみ。openmaic.yml が宣言したプロバイダーから選ぶ
管理者が設定済み すべてのスロットがロックされている(lock: all) コースモデルの図。すべてのカードが読み取り専用(各カードにモデル、または無効・未設定を表示)

すべてのコントロールは同じ規則に従います。使って何かが変わる場合にだけ表示されます。モデルサービスの各機能のタブとサービスの追加は、その機能にユーザーが設定できるスロットが残っている間だけ表示されます。Token Plan は、プランがまだロックされていないスロットを埋められる間だけ表示され、プランを接続してもロックされたスロットには触れません。コースモデル設定でロックされたカードは、管理者が固定 と表示された読み取り専用の 1 行になります。ホームページのモデル選択は、既定モデルを変更できる場合(llm がロックされておらず、言語モデルを提供するプロバイダーがある場合)にだけ表示されます。

Web アプリのモデル設定は、現在のワークスペースについて同じ木を読み書きします。

  • Token Plan:プランを接続すると、そのキーでプランのプロバイダーを追加し、プランが推奨するスロットのうちまだ割り当てのないものにモデルを設定します。接続を解除するとプロバイダーが削除され、それを使っていたスロットは親に従います。
  • モデルサービス:機能ごとのタブ(言語モデル、画像、動画、音声合成、音声認識、ドキュメント解析、Web 検索)にそれぞれのサービスが並びます。openmaic.yml のプロバイダーは読み取り専用で表示され、キーに関する情報は一切表示されません。ワークスペースが設定したサービスはワークスペースのプロバイダーになり、キーは書き込み専用で …abcd のようなマスクだけが表示されます。独自のエンドポイントは言語モデルのみ(Azure Speech はリージョンの公式エンドポイント、例えば https://<region>.tts.speech.microsoft.com を使います)、セルフホストのサービスとキーペアは openmaic.yml で設定します。テストボタンは保存済みのプロバイダーをサーバー側で確認します。
  • コースモデル設定:メインモデルは llm スロット、パイプラインの各工程はそれぞれのスロットです(メインモデルに従う=その工程に独自の割り当てがない)。画像・動画・音声・検索・文字起こしのスイッチはスロットを null にします。ロックされたスロットは読み取り専用の 1 行になり、サーバー既定のスロットはユーザーが変更すると サーバー既定に戻す を表示します。

以前のバージョンでブラウザーに保存されていたプロバイダー設定(プロバイダーとそのキーや Base URL、選択したモデル、Token Plan の接続状態、各機能の選択)は、ワークスペースに一度だけインポートされます。インポートはワークスペースの既存の設定や openmaic.yml がロックしたサブツリーのスロットを置き換えず、allowUserKeys: false や検証で拒否された項目はスキップします。2xx の応答を受けた後、ブラウザーから削除されるのはワークスペースが保持した項目だけです。サーバーがスキップした項目(メディアサービスの独自エンドポイント、ワークスペースで使えないプリセット、デプロイが宣言しているプロバイダー ID など)はキーとともにこのブラウザーに残り、再送されることはなく、設定し直すか破棄するまで「設定 → モデルサービス」に理由とともに表示されます(通知は一度だけです)。リージョンの公式エンドポイントを持つ Azure Speech のプロバイダーはインポートされます。400 の場合はインポート内容全体が拒否され、再送はされず、各項目はキーとともにこのブラウザーに残り、スキップされた項目と同様に表示されます。インポート内容が読み取れない場合は破棄されます。401、404、409、5xx、ネットワークエラーの場合だけ、後で再試行するためにブラウザーに残ります。明示的にオフにしていた音声入力は asr: null になり、使えるプロバイダーがある状態でオフにしていたナレーション・画像・動画は tts: null・image: null・video: null になります(openmaic.yml がロックしたスロットはそのままです)。次のものは手動で設定し直す必要があります:ステージ別のモデル(コースモデル設定、または openmaic.yml の slots)、推論設定(スロットの thinking)、独自の音声合成・文字起こしプロバイダーと AliDocMind のキーペア(openmaic.yml。これらはブラウザーに残り、スキップされた項目と一緒に表示されます)、VoxCPM のバックエンド(options.backend)、Baidu 検索のサブソース(サーバーの既定値が適用されます)。オフにしていた調査のスイッチは引き継がれません(コースの調査を止めるだけで、チャットとエージェントは検索を続けていました)。既定値のためにオフだっただけのスイッチも引き継がれません。各機能はスロットが解決される限り動作します。

ワークスペースとは永続化のドキュメントでいうオーナー(owner)です。既定の Docker Compose 構成(シングルユーザーモード)ではワークスペースは 1 つで、マルチユーザー構成ではサインインした各アカウントが自分のワークスペースを持ちます。

allowUserKeys

yaml
allowUserKeys: false

allowUserKeys(既定値 true)は、ユーザーが独自のプロバイダー、キー、Token Plan を追加できるかどうかを決めます。false の場合、ユーザーは openmaic.yml が宣言したプロバイダーの中から、ロックされていないスロットについてのみ選択できます。Token Plan とサービスの追加は表示されず、ワークスペースが以前に追加したプロバイダーも使われなくなります。リクエストが独自のモデル、キー、エンドポイントを指定しても(非推奨の x-model、x-api-key、x-base-url ヘッダーや、メディア系 API のプロバイダーフィールド)無視され、プロバイダーのテストボタンのヘッダー形式は拒否されます。すべての利用料を負担するマルチユーザー環境では、通常これを設定し、固定したいスロットをロックします。

シークレットと OPENMAIC_SECRET_KEY

  • openmaic.yml のキーは ${VAR} で環境変数から読み込まれ、データベースに保存されることもブラウザーに送られることもありません。
  • ワークスペースがモデル設定で入力したキーはデータベースに保存され、インスタンスシークレット OPENMAIC_SECRET_KEY を使って AES-256-GCM で暗号化されます。
  • OPENMAIC_SECRET_KEY が未設定の場合、サーバーは初回使用時に data/instance-secret.key(Docker Compose では openmaic-data ボリューム内)にランダムなシークレットを作成し、以降それを使います。

シークレットはデータベースのバックアップと一緒に保管してください。失われたり変更されたりしたシークレットで保存されたキーは読み取れず、再入力が必要です(モデル設定に表示されます)。複数のインスタンスが同じデータベースを共有する場合や、サーバーに永続的なデータディレクトリがない場合(データボリュームをマウントしていないコンテナなど)は、OPENMAIC_SECRET_KEY を明示的に設定してください。任意の文字列を使えます(例:openssl rand -base64 32 の出力)。

設定しない場合、再起動をまたいで残らないデータディレクトリ(ボリュームのないコンテナや一時ディスク)では起動のたびに新しいシークレットが作られ、複数のレプリカはそれぞれ別のシークレットを作ります。作業ディレクトリが読み取り専用ならシークレットを作成できず、モデル設定でのキー保存は失敗します。起動時、OPENMAIC_SECRET_KEY が未設定でデータディレクトリに書き込めない場合や、データベースに現在とは別のシークレットで封印されたキーがある場合(data/instance-secret.key を失った場合など)、サーバーは警告を記録しますが、起動は続けます。

bash
OPENMAIC_SECRET_KEY=...        # モデル設定に保存されるキー用のインスタンスシークレット
OPENMAIC_CONFIG=/etc/openmaic/openmaic.yml   # 任意:openmaic.yml の別のパス

ワークスペースが追加できるもの

openmaic.yml のプロバイダーはスキーマが許すものなら何でも構いません。ワークスペースがモデル設定で追加するプロバイダーは、エンドポイントとキーを Web アプリのユーザーが入力するため、より制限されます。

  • Amazon Bedrock は提供されません。サーバー自身の AWS 認証情報で署名してしまうためです。
  • proxy とキーペア(credentials)は使えません。AliDocMind などキーペアで認証するプリセットはデプロイ専用です。
  • 独自の baseUrl はチャットを提供するプリセットでのみ使え、それを持つプロバイダーはチャットスロット専用になります。URL には認証情報、クエリ文字列、フラグメントを含められず、サーバーが ALLOW_LOCAL_NETWORKS=true を設定していない限り公開アドレスに解決される必要があります。このエンドポイントへのリクエストはリダイレクトに従いません。
  • ローカルのモデルサーバー(Ollama、Lemonade)は、プリセット既定の localhost ではなく自分のエンドポイントを指定する必要があります。
  • セルフホストのメディア・検索・ドキュメントサービス(VoxCPM2、FunASR、Lemonade の音声と画像、ComfyUI、SearXNG、セルフホストの MinerU)はサーバー自身のネットワーク上で動くため、デプロイ専用です。
  • 運用者が旧来の <CAP>_<VENDOR>_ENABLED=false 変数で無効にしたプロバイダーは提供されません。
  • ワークスペースは openmaic.yml が既に宣言しているプロバイダー ID を宣言できません。

ユーザーに内部サービスを使わせたい場合は、そのプロバイダーを openmaic.yml で宣言してください。

API クライアントと非推奨のリクエストフィールド

生成リクエストでキーやモデルの選択を送る必要はなくなりました。/api/generate-classroom のようなブラウザーを使わない API 呼び出しも、呼び出し元のワークスペースについて Web アプリと同じスロットで解決されます。

旧来のリクエストヘッダー x-model、x-api-key、x-base-url、x-provider-type、x-model-routes、x-image-provider、x-image-model、x-video-provider、x-video-model、および音声・検索・ドキュメント API の対応するボディフィールドは非推奨です。これらは、影響するスロットに何も割り当てられていない間、またはレガシー変数で構成されたままのサーバーで、それらから変換された既定値しかない間だけ有効です(DEFAULT_MODEL は以前からリクエストが指定したモデルより優先度が低いため)。openmaic.yml の既定値、ワークスペース自身の選択、ロックされたスロットが優先され、null のスロットはリクエストの内容にかかわらず拒否されます。allowUserKeys: false の場合は常に無視されます。リクエストが初めてこれらを使ったとき、サーバーは警告をログに記録します。上記のモデル設定のリリースから 2 マイナーバージョン後に削除される予定です。

旧設定からの移行

openmaic.yml がない場合、サーバーは起動時に旧設定を変換し、非推奨の通知と、変換できなかった項目ごとの通知をログに出します。

旧設定 変換後
プロバイダー変数(OPENAI_API_KEY、TTS_MINIMAX_API_KEY など)と server-providers.yml デプロイのプロバイダー(モデル設定では読み取り専用)。プロバイダー ID はプリセット ID で、Web 検索の MiniMax(minimax-search)、Web 検索の Doubao(doubao-search)、Lemonade の画像(lemonade-image)を除き旧来のプロバイダー ID と同じです
_MODELS プロバイダーの models
DEFAULT_MODEL llm の割り当て。ワークスペースが上書きできる既定値として扱われます(ロックされません)。agent スロットはこれまでどおり無効のままです
MODEL_FALLBACK DEFAULT_MODEL のフォールバック。DEFAULT_MODEL がない場合は通知付きで破棄されます
最初に設定された TTS、ASR、画像、動画、ドキュメントのプロバイダーと、旧来の優先順で選ばれた Web 検索 その機能の既定の割り当て(画像プロバイダーは DEFAULT_IMAGE_PROVIDER で指定)
MODEL_ROUTES 変換されません。 サーバーは起動を拒否します(後述)
<CAP>_<VENDOR>_ENABLED=false 引き続き有効。誰が割り当てても、そのプロバイダーは無効のままです

openmaic.yml がある場合、プロバイダー変数、server-providers.yml、DEFAULT_MODEL、MODEL_FALLBACK、MODEL_ROUTES はモデルの解決に使われず、その旨が通知されます。<CAP>_<VENDOR>_ENABLED=false は引き続き適用されます。機能を無効にするには、代わりにスロットを null にしてください。

環境変数。 移行前:

bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=openai:gpt-5.5
MODEL_FALLBACK=anthropic:claude-sonnet-4-6
TTS_MINIMAX_API_KEY=...
TAVILY_API_KEY=tvly-...

移行後(キーは環境変数に残します):

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  minimax-tts:
    preset: minimax-tts
    apiKey: ${TTS_MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}

slots:
  llm:
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
  tts: minimax-tts
  webSearch: tavily

その後 DEFAULT_MODEL と MODEL_FALLBACK を削除します。プロバイダー変数は ${VAR} 参照の元として残して構いません。_BASE_URL はプロバイダーの baseUrl になり(OPENAI_BASE_URL → openai の baseUrl。URL を直接書くか、変数を設定したままなら ${OPENAI_BASE_URL} と書きます)、_MODELS はその models リストになります。変換された DEFAULT_MODEL と同じく、ここに書いた llm はワークスペースが変更できるサーバーの既定値です。全員に対して固定するには lock: [llm] を加えてください。変換結果と異なり、agent は llm に従うようになります。Pro エージェントを無効のままにするには agent: null と書いてください。

server-providers.yml。 移行前:

yaml title="server-providers.yml"
providers:
  azure:
    apiKey: ...
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
pdf:
  alidocmind:
    accessKeyId: ...
    accessKeySecret: ...
web-search:
  searxng:
    baseUrl: http://searxng:8080

移行後:

yaml title="openmaic.yml"
providers:
  azure:
    preset: azure
    apiKey: ${AZURE_OPENAI_API_KEY}
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
  alidocmind:
    preset: alidocmind
    credentials:
      accessKeyId: ${ALIDOCMIND_ACCESS_KEY_ID}
      accessKeySecret: ${ALIDOCMIND_ACCESS_KEY_SECRET}
  searxng:
    preset: searxng
    baseUrl: http://searxng:8080

slots:
  llm: azure:my-deployment
  document: alidocmind
  webSearch: searxng

server-providers.yml の各セクションは、対応するプリセットのプロバイダーになります(providers → チャットのプリセット、tts、asr、pdf → ドキュメント、image、video、web-search)。accessKeyId/accessKeySecret は credentials の下に移します。

MODEL_ROUTES。 openmaic.yml なしで MODEL_ROUTES を設定したサーバーは起動しません。

text
MODEL_ROUTES does not carry over to the model configuration: write the per-stage models as slots in openmaic.yml (or the file named by OPENMAIC_CONFIG) and remove MODEL_ROUTES

ステージ別のルートはスロットと 1 対 1 に対応しないため、変換されません。各ルートを、そのステージが属するスロットとして書き直してください。

MODEL_ROUTES のステージ スロット
web-search-query-rewrite course.research
scene-outlines-stream course.outline
agent-profiles course.agents
scene-content course.content
scene-content:slide、:quiz、:interactive、:pbl course.content.slide、.quiz、.interactive、.pbl
scene-actions course.actions
chat-adapter、quiz-grade、pbl-v2-runtime とその :instructor、:open-task、:evaluate、:simulator classroom
generate-classroom llm
maic-agent-driver agent
conversation-title agent.title
pbl-chat、maic-agent なし:これらのステージは削除されました。削除してください

複数のステージが 1 つのスロットを共有する場合は、それらが使うべき 1 つのモデルをスロットに指定します。ルートの thinking はスロットの thinking に、maic-agent-driver ルートの api と contextWindow は agent スロットに移します。移行前:

bash
DEFAULT_MODEL=openai:gpt-5.5
MODEL_ROUTES='{"scene-outlines-stream":"anthropic:claude-sonnet-4-6","scene-content:slide":{"model":"openai:gpt-5.4","thinking":{"effort":"low"}},"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'

移行後:

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}

slots:
  llm: openai:gpt-5.5
  course.outline: anthropic:claude-sonnet-4-6
  course.content.slide:
    model: openai:gpt-5.4
    thinking: { effort: low }
  agent:
    model: openai:gpt-5.5
    api: openai-completions

openmaic.yml が存在すると、プロバイダー変数だけでは何も設定されません。変数だけで設定していた TTS、画像、ドキュメント、Web 検索のプロバイダー(TTS_MINIMAX_API_KEY、IMAGE_SEEDREAM_API_KEY、PDF_MINERU_BASE_URL、TAVILY_API_KEY など)は使われなくなり、webFetch 以外の機能にはデフォルトのプロバイダーは割り当てられません。使用するプロバイダーをすべて providers に宣言し、上の環境変数の例と同様に slots で tts、image、document、webSearch(および asr、video)を割り当ててください。割り当てられていないその他のスロットは、ワークスペースがモデル設定で割り当てない限り未設定のままです。

その後 MODEL_ROUTES、DEFAULT_MODEL、MODEL_FALLBACK を削除します。

Docker。 ファイルをアプリの隣にマウントし、キーは .env.local に置きます。

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

ACCESS_CODE — サイト全体のパスワード

共有デプロイ(社内デモ、授業など)ではアクセスコードを設定し、訪問者にアプリ表示前のパスワード入力を求められます。

bash
ACCESS_CODE=your-secret-code

十分な長さのランダムな値(16 文字以上)を使用してください。このコードはデプロイを保護する唯一の秘密です。検証に成功すると、署名付きトークンが HTTP-only cookie に 7 日間保存され、有効期限はサーバー側で検証されるため、期限切れ後は再入力が必要です。検証レートの制限は、x-forwarded-for / x-real-ip を上書きする信頼できるリバースプロキシの背後で TRUST_PROXY_HEADERS=true を設定した場合にのみ有効になり、クライアント単位で 60 秒あたり 10 回に制限されます。検証に成功するとそのクライアントのカウントは消去されます。信頼できるプロキシがない場合、リクエストをクライアントに紐付けられないため制限は行われず、コードの長さとランダム性が防御になります。空欄にすると無効になります。

機能フラグ

機能フラグは true または 1 を有効とし、特記がない限りそれ以外は無効です。NEXT_PUBLIC_* はビルド時にクライアントに注入されるため、変更後は再ビルドが必要です。

bash
# MAIC Editor(Pro モード)
NEXT_PUBLIC_MAIC_EDITOR_ENABLED=true

# Pi 再生時の PPT・インタラクティブ教材参照(ビルド時フラグ。編集モードの参照には影響しません)
NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED=true

# 職業教育タスクエンジン(サーバー側スイッチ)
OPENMAIC_ENABLE_VOCATIONAL=true

# 職業教育実験 UI 表示
NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI=true

# 動画エクスポート入口表示
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true

# 実験的 PPTX インポート入口表示
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true

教室チャットは常に Pi で動作し、ツール/関数呼び出しに対応したモデルが必要です。コースモデル設定の教室で対応モデルを選択するか、openmaic.yml の classroom スロット(または継承元の llm スロット)を設定してください。Gemini では Google の OpenAI 互換エンドポイントではなく、ネイティブの google プリセットを使います。 組み込みカタログでツール呼び出し非対応とされているモデルは推論前に 400 MODEL_CONFIG_INVALID(reason: tool_calling_required)で拒否され、コースモデル設定でも教室スロットにこの要件が表示されます。

従来の director-graph 教室チャットは削除され、NEXT_PUBLIC_PI_CHAT_ENABLED で対話経路を選ぶことはできなくなりました。この変数は設定から削除してください。production ビルドが受け付けるのは未設定、空文字列、true、1(完全一致)のみで、それ以外の値では移行案内とともにビルドが失敗します。Docker Compose では、サービスの env_file(.env.local)が渡すのは実行時の値であり、ビルド引数には入りません。

PPTX インポートはまだ実験的で、解析結果は完全に授業データフローに統合されていません。NEXT_PUBLIC_* 変数はビルド時スイッチで、Docker では build args で渡す必要があり、コンテナ実行時環境変数だけでは反映されません。

その他サーバーオプション

以下のサーバーオプションも環境変数で設定可能です:

bash
# シーン内容の並列生成。0 または未設定は逐次生成
PARALLEL_SCENE_CONCURRENCY=3

# localhost や内部ネットワークへのアクセス許可。セルフホスト/プライベートネットワーク向け
ALLOW_LOCAL_NETWORKS=true

# 任意の MP4 レンダリングサービス
RENDER_SERVICE_URL=http://render-service:9000

# ログと推論設定
LOG_LEVEL=info
LOG_FORMAT=pretty
LLM_THINKING_DISABLED=false

旧来のプロバイダー環境変数

これらの変数と server-providers.yml は以前のリリースの設定方法です。openmaic.yml がない場合は引き続き動作しますが(旧設定からの移行を参照)、非推奨であり、モデル設定のリリースから 2 マイナーバージョン後に削除されます。openmaic.yml を使う場合は ${VAR} 参照の元としてのみ使ってください。DEFAULT_MODEL と MODEL_FALLBACK は移行のセクションで説明しています。MODEL_ROUTES はもう読み込まれません。

LLM プロバイダー

クラウドプロバイダーは通常、API key、base URL、モデル一覧の3つの環境変数を使います。API key は必須の場合が多く、base URL とモデル一覧は任意です。Azure OpenAI はリソース endpoint の設定が必要で、Ollama と Lemonade は API key 不要です。

bash
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=                # 任意の base URL 上書き
OPENAI_MODELS=                  # 任意の許可モデル一覧(カンマ区切り)

対応している provider prefix:

Prefix プロバイダー
OPENAI_ OpenAI
AZURE_OPENAI_ Azure OpenAI
ANTHROPIC_ Anthropic
GOOGLE_ Google Gemini
DEEPSEEK_ DeepSeek
QWEN_ Alibaba Qwen
KIMI_ Moonshot Kimi
MINIMAX_ MiniMax(既定では Anthropic 互換エンドポイント)
GLM_ Zhipu GLM
SILICONFLOW_ SiliconFlow
DOUBAO_ Doubao(ByteDance)
OPENROUTER_ OpenRouter
GROK_ xAI Grok
TENCENT_ Tencent Hunyuan
TENCENT_HUNYUAN_ Tencent Hunyuan(別名)
XIAOMI_ Xiaomi MiMo
MIMO_ Xiaomi MiMo(別名)
OLLAMA_ Ollama(ローカル)
LEMONADE_ Lemonade(ローカル)

Azure OpenAI は deployment name をモデル ID として使います:

bash
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=your-deployment-name

別の OpenAI 互換 LLM サービスに接続するには、openmaic.yml で baseUrl と models を指定した openai-compatible プロバイダーを宣言するか、設定 → モデルサービス → 言語モデル の プロバイダーを追加 で追加してください。

Gemini の教室チャットにはネイティブの google プリセットを使います。設定 → モデルサービスで Google を追加し、コースモデル設定の教室で選択してください。Pi は Google 公式の OpenAI 互換エンドポイント(https://generativelanguage.googleapis.com/v1beta/openai)を早期に拒否し、移行を案内します。互換経路のツール呼び出し対応は #1556 で追跡しています。このチェックは公式エンドポイントのみを識別し、任意のサードパーティゲートウェイの接続先は識別できません。

ローカルモデル(Ollama と Lemonade)

Ollama と Lemonade は API key 不要です。ローカルサービスの base URL はサーバー側設定に書き、SSRF チェックを通過させます。

bash
OLLAMA_BASE_URL=http://localhost:11434/v1
# LEMONADE_BASE_URL=http://localhost:13305/v1

利用可能モデルを制限したい場合は、OLLAMA_MODELS または LEMONADE_MODELS でホワイトリストを指定できます。

TTS プロバイダー

サーバー側 TTS プロバイダーは TTS__API_KEY と任意の TTS__BASE_URL を使います。

bash
# Doubao TTS(Volcengine Seed-TTS、ネイティブ MP3)
TTS_DOUBAO_API_KEY=appId:accessKey
TTS_DOUBAO_BASE_URL=       # 任意の上書き

# Qwen TTS
TTS_QWEN_API_KEY=
TTS_QWEN_BASE_URL=         # 任意の上書き

# 任意の OpenAI 互換 TTS エンドポイント
TTS_OPENAI_API_KEY=
TTS_OPENAI_BASE_URL=

# VoxCPM2(セルフホスト TTS、音声クローン対応。VoxCPM2 ページを参照)
TTS_VOXCPM_BASE_URL=http://localhost:8000

対応している TTS prefix は TTS_OPENAI_、TTS_AZURE_、TTS_GLM_、TTS_QWEN_、TTS_MINIMAX_、TTS_DOUBAO_、TTS_ELEVENLABS_、TTS_GOOGLE_、TTS_VOXCPM_、TTS_LEMONADE_ です。ローカル Lemonade TTS と VoxCPM2 は API key 不要です。ブラウザのネイティブ TTS はサーバー設定不要です。

管理者は TTS__ENABLED=false で特定の TTS プロバイダーをサーバー側で強制無効化できます。VoxCPM2(セルフホスト TTS+音声クローン)については専用の VoxCPM2 ページを参照してください。

別の OpenAI 互換 TTS エンドポイントを使うには、openmaic.yml でその baseUrl を指定した openai-tts プロバイダーを宣言し、tts スロットに割り当ててください。ワークスペースはモデル設定で独自の TTS エンドポイントを追加できません。

ASR(音声認識)

bash
# OpenAI Whisper
ASR_OPENAI_API_KEY=
ASR_OPENAI_BASE_URL=       # 任意の上書き

# Qwen ASR
ASR_QWEN_API_KEY=
ASR_QWEN_BASE_URL=         # 任意の上書き

# Azure ASR
ASR_AZURE_API_KEY=
ASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com

# FunASR(ローカル、key 不要)
ASR_FUNASR_BASE_URL=http://localhost:8000/v1

# Lemonade ASR(ローカル、key 不要)
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1

funasr-server --device cuda で OpenAI 互換サービスを起動します。/v1/audio/transcriptions で WAV を受け付け、モデル選択から SenseVoiceSmall、Paraformer、Fun-ASR-Nano を利用できます。

ブラウザのネイティブ ASR はサーバー設定不要です。

別の OpenAI 互換 ASR エンドポイントを使うには、openmaic.yml で openai-whisper プリセットとその baseUrl を指定して宣言し、asr スロットに割り当ててください。ワークスペースはモデル設定で独自の ASR エンドポイントを追加できません。

yaml title="openmaic.yml"
providers:
  my-asr:
    preset: openai-whisper
    apiKey: ${MY_ASR_API_KEY}
    baseUrl: https://asr.example.com/v1

slots:
  asr: my-asr:whisper-1

画像生成プロバイダー

画像生成プロバイダーは IMAGE__API_KEY と任意の IMAGE__BASE_URL を使います。対応 prefix は以下の通りです:

IMAGE_OPENAI_、IMAGE_SEEDREAM_、IMAGE_QWEN_IMAGE_、IMAGE_NANO_BANANA_、IMAGE_MINIMAX_、IMAGE_GROK_、IMAGE_GLM_、IMAGE_LEMONADE_

GLM Image(Zhipu CogView)は CogView モデル(cogview-3-flash、cogview-4)で画像を生成します:

yaml title="openmaic.yml"
providers:
  glm-image-service:
    preset: glm-image
    apiKey: ${IMAGE_GLM_API_KEY}
    # 任意:
    baseUrl: https://open.bigmodel.cn/api/paas/v4

slots:
  image: glm-image-service:cogview-3-flash

環境変数でも設定できます:

bash
IMAGE_GLM_API_KEY=
# 任意:
IMAGE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

Lemonade はローカルサービスで API key 不要です:

bash
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1

ComfyUI Image に API キーは不要です。自分のネットワーク上で動くため、デプロイ側で設定します。openmaic.yml で ComfyUI サーバーのアドレスを指定して宣言し、image スロットに割り当ててください。

yaml title="openmaic.yml"
providers:
  comfyui:
    preset: comfyui-image
    baseUrl: http://host.docker.internal:8188

slots:
  image: comfyui

API 形式でエクスポートしたワークフロー JSON を OpenMAIC の public/ ディレクトリに置きます。ファイル名は comfyui-*.json に一致するか workflow を含む必要があり、OpenMAIC はこれらを自動的に検出してワークフローとして提供します。Docker では、イメージをビルドする前にワークフローを追加するか、個々のワークフローファイルをコンテナ内の /app/public/ にマウントしてください。プロバイダーはデプロイで宣言されているため、ホストやプライベートネットワーク上の ComfyUI に ALLOW_LOCAL_NETWORKS は不要です。

動画生成プロバイダー

動画生成プロバイダーは VIDEO__API_KEY と任意の VIDEO__BASE_URL を使います。対応 prefix は:

VIDEO_SEEDANCE_、VIDEO_KLING_、VIDEO_SORA_、VIDEO_MINIMAX_、VIDEO_GROK_、VIDEO_HAPPYHORSE_

ドキュメント・メディア解析

教材の具体的なフォーマットは選択したパーサーに依存します。現在はテキスト、PDF、Office 文書、画像、一部の音声・動画形式をサポートし、provider によって対応範囲が異なります。

bash
# MinerU 自ホスト
PDF_MINERU_BASE_URL=http://localhost:8888

# MinerU 自ホストのオプションバックエンド
PDF_MINERU_BACKEND=pipeline

# MinerU Cloud
PDF_MINERU_CLOUD_API_KEY=
PDF_MINERU_CLOUD_BASE_URL=https://mineru.net/api/v4

# AliDocMind(API key ではなく Alibaba Cloud AccessKey を使用)
ALIDOCMIND_ACCESS_KEY_ID=
ALIDOCMIND_ACCESS_KEY_SECRET=
ALIDOCMIND_BASE_URL=       # 任意の上書き

unpdf は OpenMAIC に組み込み済みで、基本的な PDF 解析に使えます。Office 文書、画像、OCR、表、数式、レイアウト解析が必要な教材は MinerU または AliDocMind の互換 provider を選択してください。

AliDocMind のドキュメント解析は PDF、DOCX、PPTX、XLSX、PNG、JPG/JPEG、BMP、GIF に対応します。現在の音声・動画素材は AliDocMind でのみ解析可能で、動画は MP4、MOV、AVI、MKV、WMV、音声は MP3、WAV、AAC に対応し、M4A は非対応です。

Web 検索

Tavily、Exa、Firecrawl、Bocha、Brave、Baidu、SearXNG、MiniMax を設定可能です。

bash
TAVILY_API_KEY=
TAVILY_BASE_URL=           # 任意の上書き

EXA_API_KEY=
EXA_BASE_URL=https://api.exa.ai  # 任意の上書き

FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev  # 任意の上書き

BOCHA_API_KEY=
BOCHA_BASE_URL=            # 任意の上書き

BAIDU_API_KEY=
BAIDU_BASE_URL=https://qianfan.baidubce.com  # 任意の上書き

# 自ホスト SearXNG(API key 不要)
SEARXNG_BASE_URL=

WEB_SEARCH_MINIMAX_API_KEY=
WEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com  # 任意の上書き

Brave と SearXNG は API key 不要です。Web 検索には生成ごとのスイッチはなく、ワークスペースの webSearch スロットが解決される限り実行されます。無効にする(openmaic.yml で webSearch: null、または 設定 → コースモデル設定 の検索スイッチで無効化)と、そのワークスペースの以降の生成に影響します。Grok の Web 検索は Grok LLM の検索ツール経由で提供され、独立した Web 検索 provider ではありません。

server-providers.yml

openmaic.yml に置き換えられ、非推奨です。両方がある場合、モデルの設定には openmaic.yml だけが使われます。

環境変数のほかに、コード内で登録済みの provider 設定をプロジェクトルートの server-providers.yml に書くこともできます。サーバー起動時に読み込まれ、環境変数のカテゴリに対応した構造です。

yaml title="server-providers.yml"
providers:
  openai:
    apiKey: sk-...
    baseUrl: https://api.openai.com/v1
    models:
      - gpt-5.5
  anthropic:
    apiKey: sk-ant-...

tts:
  doubao-tts:
    apiKey: appId:accessKey

asr:
  openai-whisper:
    apiKey: sk-...

pdf:
  mineru:
    baseUrl: http://localhost:8888
  alidocmind:
    accessKeyId: your-access-key-id
    accessKeySecret: your-access-key-secret

image:
  seedream:
    apiKey: ...

video:
  seedance:
    apiKey: ...

web-search:
  tavily:
    apiKey: tvly-...
  exa:
    apiKey: ...
  firecrawl:
    apiKey: fc-...
  searxng:
    baseUrl: http://localhost:8080

環境変数は YAML の同名フィールドを逐次上書きします。キーはコード内登録済みの provider ID(例:openai、doubao-tts、openai-whisper、mineru、alidocmind、seedream、seedance、exa、tavily、searxng)でなければなりません。未知の ID でカスタム LLM provider を登録することはできません。

サーバー管理プロバイダーのモデル認可

デプロイの認証情報を使って選択されたモデルを上流へ送るプロバイダーには、openmaic.yml で明示的な models リスト(providers..models)を設定するか、対応する *_MODELS 環境変数 / server-providers.yml の該当セクションの models リストを設定してください。対象はチャット、画像、動画、音声合成、音声認識、モデルベースの Web 検索です。モデル ID は設定読み込み時に前後の空白を除いたうえで、大文字小文字も含め完全一致で照合されます。決定的なアダプターエイリアスは、宣言されたモデル ID で認可されます。リストにないモデルへのリクエストは HTTP 403、PROVIDER_DISABLED、メッセージ「This model is not authorized for server-managed credentials」を返します。

破壊的変更: 明示的なリストがない場合に使えるのは、デプロイの既定/フォールバックモデルと、その機能のスロットが参照するプライマリ/フォールバックモデルだけです。旧設定では DEFAULT_MODEL と MODEL_FALLBACK がチャットモデルを認可します。プロバイダーのみを指定したメディアスロットは、そのアダプターの既定モデル 1 つだけを認可し、プリセットのカタログ全体は認可しません。ワークスペースの割り当てでデプロイの認可範囲を広げることはできません。音声登録と合成でも、音声互換性のために選ばれたモデルを認可する必要があります。

移行するには、ユーザーが選べるべきモデルをすべて列挙し、サーバーを再起動し、認可されなくなったワークスペースの選択を更新してください。認証情報があってリストのないプロバイダーについては、追加すべき設定が起動ログに表示されます。ユーザー自身のプロバイダー認証情報と、Ollama や Lemonade のような key 不要のローカルプロバイダーは影響を受けません。認証情報なしで設定されたプロバイダーは制限されないため、独自に上流の認証情報を付加するゲートウェイ(社内の OpenAI 互換プロキシなど)に向けないでください。そのゲートウェイに key を与えて models リスト付きの認証情報ありプロバイダーとして設定するか、ゲートウェイ側でモデルを制限してください。モデル選択のないドキュメントパーサーや検索サービス(Tavily など)にはモデルリストは不要です。

yaml
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
    models: [gpt-5.4-mini]
slots:
  llm: openai:gpt-5.4-mini

旧設定での同等の書き方:OPENAI_API_KEY=... と OPENAI_MODELS=gpt-5.4-mini。メディアでは機能プレフィックスを使います。例:IMAGE_SEEDREAM_MODELS、VIDEO_SEEDANCE_MODELS、TTS_OPENAI_MODELS、ASR_OPENAI_MODELS、WEB_SEARCH_CLAUDE_MODELS。

DeepSeek の Web 検索と取得

deepseek プリセットはチャット、Web 検索、ホスト型 Markdown 抽出で DEEPSEEK_API_KEY と API ベース URL を共有します。検索は POST /v1/tools/search を使い、最大 50 件を返します。クエリは Unicode コードポイント単位で 128 文字に切り詰め、結果には summary、空の場合は snippet を使います。取得は POST /v1/tools/fetch を使い、content と空でない任意の title を読み取ります。

旧設定では WEB_SEARCH_DEEPSEEK_ENABLED=true、または server-providers.yml の明示的な web-search.deepseek エントリーで有効にします。チャットの認証情報だけでは検索を有効にせず、既存の検索の既定値も変更しません。WEB_SEARCH_DEEPSEEK_ENABLED=false は検索を強制無効化します。openmaic.yml がある場合、旧変数はスロットを割り当てられません。プロバイダーを宣言し、webSearch と/または webFetch を明示的に割り当ててください。

The default API base is https://api.deepseek.com/v1. On api.deepseek.com, every base path (including /beta) is normalized to /v1 before appending /tools/search or /tools/fetch. Custom gateway bases preserve their configured path prefix; a bare root gets /v1 first. Custom endpoints belong in deployment configuration, while client and workspace overrides remain restricted to official endpoints. The default fetch reader remains builtin. See DeepSeek official pricing for current request charges. Failed requests and searches with no results are not billed.

yaml
providers:
  deepseek:
    preset: deepseek
    apiKey: ${DEEPSEEK_API_KEY}
    baseUrl: https://api.deepseek.com/v1
slots:
  webSearch: deepseek
  webFetch: deepseek

Kimi のチャットと Web 検索

Kimi アカウントを 1 つ宣言し、両方のスロットを割り当てます:

yaml
providers:
  kimi:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    # 任意:国際版 Open Platform では https://api.moonshot.ai/v1 を使用します。
    # baseUrl: https://api.moonshot.ai/v1
slots:
  llm: kimi:kimi-k3
  webSearch: kimi

Coding Plan のキーには preset: kimi-coding-plan と llm: kimi:kimi-for-coding を指定します。既定のベース URL は https://api.kimi.com/coding/v1 です。Open Platform と Coding Plan のキーは、それぞれに対応するベース URL と組み合わせる必要があります。 旧設定では、明示的に有効化した場合にのみ KIMI_API_KEY と任意の KIMI_BASE_URL が検索の認証情報を提供します。WEB_SEARCH_KIMI_ENABLED=true を設定するか、server-providers.yml に web-search.kimi を追加します(例:enabled: true)。チャットの認証情報だけでは検索は有効にならず、既定の検索プロバイダーも変わりません。スロットに割り当てられていても、WEB_SEARCH_KIMI_ENABLED=false は Kimi 検索を無効にします。WEB_SEARCH_KIMI_ENABLED が Kimi 検索の唯一のスイッチです。 KIMI_MODELS はチャットにのみ適用されます。旧設定のチャットと検索でアカウントが競合すると警告が出て、検索エントリーはスキップされます。別々のアカウントを使う場合は、openmaic.yml で別々のプロバイダー ID を宣言してください。 openmaic.yml が存在する場合は、プロバイダーを宣言してスロットを明示的に割り当ててください。設定と初回起動ウィザードでは、同じアカウントから両方の機能を利用できます。

エンドポイントはベースパスに応じて選択されます。末尾のスラッシュを除いたパスが /coding/v1 で終わる場合は /search、それ以外は /tools/search_pro を使用します。デプロイで設定するカスタムプロキシも、このパス構成を維持する必要があります。ウィザードが検索を推奨するのは公式アカウントのベース URL のみなので、ワークスペースのカスタムチャットプロキシに検索が自動で割り当てられることはありません。

openmaic.yml が存在する場合は、providers.: { preset: kimi } を宣言して slots.webSearch: を割り当ててください。旧環境変数 WEB_SEARCH_KIMI_ENABLED ではこのスロットを割り当てられません。

Webページ取得のプロバイダースロット

webFetch は、エージェントの fetch_url ツールで使う読み取りサービスを選択します。従来の設定、空の設定、YAML 設定では builtin が既定であり、lock: all でこのルートが未指定の場合も同様です。webFetch: null を明示すると取得が無効になり、以降の実行からツールとそのプロンプトが除かれます。各リクエストは一つのプロバイダーだけを使用し、フォールバックや別サービスへの切り替えはありません。無効なプロバイダー参照やポリシーで拒否されるワークスペースのエンドポイントは、webSearch と同様に、エージェント開始前の機能解決時に実行全体を失敗させます。これは設定エラーであり、fetch_url の失敗結果ではありません。組み込みサービスへの切り替えも行いません。

プロバイダー 認証情報 抽出
builtin 不要 ローカルの Readability/Turndown。PDF は document スロットを使用
kimi 既存の Kimi プロバイダーのキーとベース URL ホスト型の Markdown 抽出。PDF の URL にも対応
deepseek 既存の DeepSeek チャットキーと API ベース ホスト型 Markdown 抽出
firecrawl Firecrawl の API キー(preset: firecrawl) ホスト型の Markdown 抽出(ページの本文)。PDF の URL にも対応
yaml
providers:
  reader:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    baseUrl: https://api.moonshot.cn/v1
slots:
  webFetch: reader

kimi と kimi-coding-plan の両プリセットが webFetch を提供します。Coding Plan の認証情報には preset: kimi-coding-plan を使います。既定のベース URL は https://api.kimi.com/coding/v1 で、明示したベース URL は保持されます。Moonshot Open Platform のベース URL(https://api.moonshot.cn/v1、https://api.moonshot.ai/v1)には /tools/fetch、Coding Plan には /fetch を使用します。いずれも Bearer 認証で { "url": ... } を含む POST を送ります。アダプターは markdown または content を受け付けます。メンテナーが実際のキーで POST https://api.kimi.com/coding/v1/fetch をテストし、応答に { markdown, title, url } が含まれることを確認しました。国際エンドポイントの利用可否は引き続き未検証です。リクエストはプロバイダーの proxy 設定と標準のプロキシ環境変数に従います。

DeepSeek は deepseek プリセットでホスト型 Markdown 抽出も提供し、チャットのキーと API ベースを共有します。上の例のように webFetch を明示的に割り当てます。/v1/tools/fetch を使い、デプロイで指定したゲートウェイのベースを保持します。タイムアウトと転送エラーのみ 1 回再試行する動作は Kimi と同じで、HTTP エラーや不正な応答は再試行しません。

設定 → モデルサービス → Webページ取得 で既存の認証情報を使用し、コースモデル設定 → Webページ取得 でサービスを選びます。キーは同じプロバイダーのチャットと共有され、変更や削除はチャットにも影響します。管理者が固定したカードは読み取り専用です。初回設定では、別のサービスを明示的に選ぶまで組み込みの既定値を維持します。ワークスペースの認証情報は公式 API のベース URL を使用でき、独自の取得エンドポイントとプロキシは管理者が openmaic.yml に記述する必要があります。

kimi と異なり、firecrawl は専用のキーを使います。openmaic.yml で preset: firecrawl のプロバイダーと apiKey(例: ${FIRECRAWL_API_KEY})を宣言するか、設定 → モデルサービス → Webページ取得 でキーを入力します。Bearer 認証で POST /v2/scrape を送り、ページの本文を Markdown で取得します。既定のベース URL は https://api.firecrawl.dev です。セルフホストの Firecrawl のベース URL は、管理者が openmaic.yml で宣言する必要があります。

同じ firecrawl プロバイダーは Web 検索(POST /v2/search)にも使えます。1 つのキーとベース URL で両方をまかなうため、1 つのプロバイダーを slots.webSearch と slots.webFetch の両方に割り当てられます。従来の FIRECRAWL_API_KEY と FIRECRAWL_BASE_URL 変数はこのプロバイダーを設定します。webFetch をこのプロバイダーに割り当てるまで、Webページ取得は builtin のままです。

ホスト型サービスに URL を送る前に、セッションの URL 許可、厳密な URL 正規化、ローカル DNS の全アドレス検査を行います。返された最終 URL も、内容を受け入れる前に同じ検査を受けます。ホスト型サービス自身の DNS と中間リダイレクトはサービス側が制御するため、ローカルの検査ではそのネットワーク接続を固定できません。返されたテキストは信頼できないデータのままであり、認証チャレンジページの検出、バイト数制限、切り詰め表示、既存の Web 素材保存と {trusted, untrusted} の結果形式を適用します。


Packages Docs Content Docs Configuration (`packages/docs/content/docs/configuration.mdx`)


title: Configuration

OpenMAIC has three configuration sources, and each has one job:

Source Written by Holds Takes effect
openmaic.yml The operator Providers, the server's default assignments, what is locked, and whether users may add keys At startup (restart to apply)
Model settings in the web app Users of a workspace Their own providers, and assignments for every slot openmaic.yml does not lock, stored in the database Immediately
Environment variables (.env.local, a container secret) The operator Secrets that openmaic.yml references, and every other server option At startup

DATABASE_URL is required; other environment variables are optional (see Deployment). Providers that use server credentials for model selection also need authorized model lists, as described below. See .env.example for environment variables, openmaic.example.yml for a sample model configuration, and Supported models for preset and model IDs.

Earlier releases configured models only through provider environment variables, server-providers.yml, DEFAULT_MODEL and MODEL_ROUTES. Those still work for now, with the exception of MODEL_ROUTES, and are deprecated; see Migrating from the legacy configuration.

Models and providers

Concepts

  • Provider: an account the server can call, such as an OpenAI key, a MiniMax token plan or a self-hosted OpenAI-compatible server. Each provider has an id you choose and a preset that says which vendor it is and which capabilities it covers.
  • Slot: a use of AI in the product, such as the course outline, slide content, text to speech or web search. Slots form one tree per capability; a slot without an assignment follows its parent.
  • Assignment: which provider and model a slot uses (provider:model), optionally with call parameters and a fallback model, or null to turn the slot off.

A capability is available exactly when its slot resolves to an assignment. There are no separate on/off switches.

openmaic.yml

The server reads openmaic.yml from the directory it starts in (the repository root; /app in the Docker image). Set OPENMAIC_CONFIG to use another path; a relative path is resolved against the same directory, and the file must then exist. Without the file, the server uses the web settings and, while they are still set, the legacy variables.

The file is read once at startup: restart the server after changing it. It is validated completely before the server starts. An unset ${VAR}, an unknown preset or slot, a reference to a provider that is not declared, or a provider that does not offer a slot's capability stops the server, and each problem is listed with its location:

text
[boot] Invalid server configuration; the server will not start: Invalid model configuration in /app/openmaic.yml:
  - providers.openai.apiKey: environment variable OPENAI_API_KEY is not set
  - slots.course.outlines: unknown slot

Keep secrets out of the file itself: reference them as ${VAR} and set the variable in the environment. Values that came from a variable are never printed in these messages.

A complete example

text
/* Detailed source-code truncated for AI context efficiency. */

With this file, the slots written under slots are the server's defaults: users may change each of them in the model settings, except tts, which lock fixes for everyone. A chat slot it leaves out, such as course.agents, follows its parent (llm); a capability root it leaves out, such as asr or document, stays unconfigured until a user assigns it. With allowUserKeys: false, users choose only among the providers declared here.

Providers

Each entry under providers declares one provider. The key is its id, which slots use in references: 1 to 63 lowercase letters, digits and dashes, starting with a letter or digit.

Field Required Meaning
preset Yes Which built-in preset this provider uses. See Supported models → Presets.
apiKey Usually The API key. Keyless services (local servers, Brave, SearXNG, unpdf) omit it.
baseUrl Some presets Endpoint to call instead of the preset's own. Required for openai-compatible, azure, searxng and mineru.
models For server-managed credentials Authorized model ids. Deployment slot defaults/fallbacks for the capability are also authorized; catalogues and workspace assignments do not grant additional models.
proxy No HTTP proxy URL for this provider's requests.
credentials Some presets Key pairs for services that do not use one key. AliDocMind takes accessKeyId and accessKeySecret.
options No Non-secret, provider-specific settings, such as backend for voxcpm-tts. Names are letters, digits, _ and -; values are strings, numbers or booleans (no nested maps or lists), and ${VAR} works. The model settings show options, so never put a key in them. Deployment only: the model settings cannot set them.

Most presets cover one capability, following the built-in registries: the openai preset is chat only, and OpenAI images and speech are the openai-image, openai-tts and openai-whisper presets. Declare one provider per capability, and reference the same ${VAR} when one key covers several. Token plan presets (minimax, tokendance, volcengine-ark, kimi-coding-plan) cover several capabilities with one key, each at the plan's own endpoint. A baseUrl on a provider replaces the endpoint for every capability that provider covers, so give a token plan a baseUrl only when all of its capabilities move with it.

Amazon Bedrock (preset: bedrock) signs requests with the AWS credential chain of the server (environment, profile or role) and reads the region from BEDROCK_REGION, AWS_REGION or AWS_DEFAULT_REGION; apiKey is an optional bearer token.

Slots

Slot Capability Parent What it drives
llm chat — The default chat model: every chat call no slot below covers
course.research chat llm Rewriting the web search query while researching a course
course.outline chat llm Scene outlines
course.agents chat llm Teacher and classmate agent profiles
course.content chat llm Scene content, for scene types without a slot of their own
course.content.slide chat course.content Slide scenes
course.content.quiz chat course.content Quiz scenes
course.content.interactive chat course.content Interactive scenes
course.content.pbl chat course.content Project-based learning scenes
course.actions chat llm Scene actions (what the agents say and do on each scene)
classroom chat llm Classroom chat, quiz grading and the project-based learning runtime
agent chat llm The Pro workbench agent. Requires tool calling. Also accepts api and contextWindow
agent.title chat agent Pro session titles. Configurable in openmaic.yml only; the model settings do not show it
tts tts — Text to speech
asr asr — Speech recognition (voice input, audio and video transcripts)
image image — Image generation
video video — Video generation
webFetch webFetch — Web page extraction (default: builtin)
webSearch webSearch — Web search
document document — The document parsing service (MinerU, MinerU Cloud, AliDocMind). The built-in PDF text extraction needs no service

How a slot resolves:

  • Resolution starts at the slot of the call and walks up to its capability root. The first assignment found wins, so a parent's assignment is the default for its whole subtree. Changing a parent changes every child without its own assignment.
  • Inside a locked subtree (the slot or an ancestor is named by lock, or lock: all), only openmaic.yml counts: the walk goes up to the locked slot and the first assignment the file writes wins. Everywhere else the user's choice wins: the walk first looks for the workspace's own assignment from the slot up to the root, and only when there is none, for the defaults openmaic.yml writes, again from the slot up. So a slot whose server default the workspace has not changed follows the workspace's choice on a parent; server values are only defaults.
  • null stops the walk: the slot and every slot below it that has no assignment of its own are off.
  • Reaching the root without an assignment leaves the capability unconfigured. Calls fail with an error that names the slot; there is no fallback to any vendor.
  • Requirements belong to the slot that declares them and are checked against the model it resolves to, assigned or inherited. agent requires tool calling; if the built-in catalogue says its model lacks it, the agent refuses to run instead of making do. A model the catalogue does not know is allowed. agent.title has no requirement, so it can use a smaller model.

Assignments

An assignment has one of four forms:

yaml
slots:
  llm: openai:gpt-5.5            # provider:model
  webSearch: tavily              # provider only: the provider's default model
  course.outline:                # an object with parameters
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
    thinking: { effort: low }
  video: null                    # off

The object form accepts:

Field Meaning
model Required. provider:model, or provider alone outside chat slots.
fallback A model to retry on once, in the same form. Language-model slots only; see Fallbacks.
thinking Reasoning parameters for chat models: enabled (true/false), mode (default, disabled, enabled, auto), effort (none, minimal, low, medium, high, xhigh, max), level (minimal, low, medium, high), budgetTokens, excludeReasoningOutput. Each provider adapter applies what its API supports.
api agent only: the transport dialect, openai-completions (the default) or openai-responses.
contextWindow agent only: the context window to assume for compaction, when it should be lower than the catalogue's.

The agent slot must not set thinking.effort: its tool-calling transport cannot combine reasoning effort with function tools.

Provider-only references

A reference without :model means the provider's default model: the model a token plan recommends for that capability, else the default of the built-in registry. Search and document services mostly have no model to pick, so webSearch: tavily or document: mineru-cloud is the usual form. Chat slots always name a model; llm: openai is refused at startup.

Fallbacks

Fallbacks apply to language-model slots: llm and every slot below it. A fallback on a tts, asr, image, video, webSearch, webFetch or document slot is refused, at startup for openmaic.yml and on save in the model settings.

A slot's fallback is tried once when a call on the primary model fails after its own retries with a retryable failure: a transient SDK error, a timeout, a network error, a rate limit (429), capacity (503), or empty output. An explicit quota refusal from the provider (an OpenAI-compatible code such as insufficient_quota, whatever its HTTP status) is not retried on the primary model: the call goes to the fallback at once. Content-safety refusals and other 4xx errors never fall back. The fallback applies to both regular and streaming calls and uses the slot's thinking parameters.

The fallback belongs to the assignment and is inherited with it: course.content.slide without an assignment of its own uses the model and the fallback of course.content. A slot with a requirement does not retry on a fallback that fails it.

Turning a capability off

Write null on its slot. video: null turns video generation off by default, and users may still turn it on in the model settings; add lock: [video] to keep it off for everyone. course.content.pbl: null turns off project-based learning scene generation while the rest of course.content keeps working. A call on a slot that is off fails immediately, and the media routes answer 403 instead of choosing another vendor.

Defaults, locks and the model settings

What openmaic.yml writes under slots is the server's default: users may change it in the model settings, where the card says Server default, and Reset to server default removes their change. What it names under lock is fixed by the administrator for everyone, together with its whole subtree:

yaml
lock: [llm, video]   # these slots and every slot below them
# lock: all          # every slot
  • Every slot lock names must be written under slots (null counts: video: null with lock: [video] keeps video off for everyone). An unknown slot, or a locked slot slots does not write, stops the server at startup with its position (lock.1: ...).
  • A locked slot fixes its children too: with lock: [llm], no user can set course.outline or any other language model slot, and all of them resolve from openmaic.yml alone.
  • lock: all fixes every slot, written or not; a capability root slots leaves out is then unconfigured for everyone. For webFetch, an omitted assignment uses builtin; set webFetch: null to disable it.
  • null, a server default and a lock combine as you would expect: video: null alone is off until a user turns it on; with lock: [video] it stays off.

The settings take one of three shapes, derived from the configuration:

Shape When What is shown
Set it up yourself allowUserKeys is on (the default) Token Plan, Model Services and Course Model Config
Choose a model allowUserKeys: false Only Course Model Config, choosing among the providers openmaic.yml declares
Configured by the administrator Every slot is locked (lock: all) The course model diagram, every card read-only (each shows its model, or that it is off or not set)

One rule decides every control: it is shown only if using it can change something. A Model Services tab, and adding a service there, appears only while its capability has a slot a user may set; Token Plan appears only while a plan can still fill a slot that is not locked, and connecting a plan never touches a locked slot. A locked card in Course Model Config is one read-only line marked Fixed by the administrator. The home page model picker appears only when the default model can be changed (llm is not locked and a provider offers a language model).

The model settings in the web app read and edit the same tree for the current workspace:

  • Token Plan: connecting a plan adds its provider with the key and fills the slots the plan recommends that are still unassigned. Disconnecting removes the provider; the slots that used it follow their parents again.
  • Model Services: one tab per capability (language models, images, video, text to speech, speech recognition, document parsing, web search) lists its services. Providers from openmaic.yml are shown read-only and without anything about their keys. A service the workspace sets up becomes a workspace provider: its key is write-only and shown only as a mask such as …abcd, a custom endpoint is possible for language models only (Azure Speech takes its official regional endpoint, such as https://<region>.tts.speech.microsoft.com), and self-hosted services and key pairs are configured in openmaic.yml. The test buttons check the saved provider on the server.
  • Course Model Config: the main model is the llm slot and each pipeline stage is its own slot (following the main model means the stage has no assignment of its own); the image, video, speech, search and transcription switches set their slot to null. A locked slot is one read-only line, and a slot on a server default offers Reset to server default once a user has changed it.

Provider settings that a browser stored in earlier versions (providers with their keys and base URLs, the chosen model, token plan enrollment and each capability's selection) are imported into the workspace once. The import never replaces an existing workspace setting or a slot in a subtree openmaic.yml locks, and it skips items that allowUserKeys: false or validation refuses. After a 2xx answer, only what the workspace now holds leaves the browser: every item the server skipped (a custom endpoint for a media service, a preset a workspace may not use, a provider id the deployment declares, ...) is kept in this browser with its key, never sent again, announced once, and listed in Settings → Model Services with the reason until it is set up again or discarded. An Azure Speech provider with its regional endpoint is imported. A 400 refuses the whole proposal: it is not sent again, and its items are kept in this browser with their keys and listed like the skipped ones. An unreadable proposal is dropped. The proposal stays in the browser for a later attempt only on 401, 404, 409, a 5xx or a network error. Speech input that was explicitly switched off becomes asr: null, and narration, images or video switched off while a usable provider for them was set up become tts: null, image: null or video: null (a slot openmaic.yml locks is left as it is). These have to be set up again by hand: per-stage models (in Course Model Config, or slots in openmaic.yml), thinking settings (thinking on a slot), custom speech and transcription providers and AliDocMind's key pair (in openmaic.yml; they are kept in the browser and listed with the skipped items), the VoxCPM backend (options.backend), and Baidu search sub-sources (the server's defaults apply). Research switched off is not carried over (it only stopped course research; chat and the agent kept searching), nor is a switch that was off only by default: each capability now runs whenever its slot resolves.

A workspace is an owner in the sense of the persistence documentation: with the default Docker Compose setup (single-user mode) there is one workspace, and in a multi-user deployment each signed-in account has its own.

allowUserKeys

yaml
allowUserKeys: false

allowUserKeys (default true) decides whether users may add providers, keys and token plans of their own. With false, users only choose among the providers openmaic.yml declares, for the slots it does not lock: Token Plan and adding services are not shown, and providers a workspace added earlier are no longer used. Requests that still name their own model, key or endpoint (the deprecated x-model, x-api-key and x-base-url headers, and the provider fields of the media routes) are ignored then, and the header form of the provider test buttons is refused. A multi-user deployment that pays for all usage typically sets it and locks the slots it wants fixed.

Secrets and OPENMAIC_SECRET_KEY

  • Keys in openmaic.yml come from the environment through ${VAR}. They are never stored in the database and never sent to the browser.
  • Keys a workspace enters in the model settings are stored in the database, encrypted with AES-256-GCM under the instance secret OPENMAIC_SECRET_KEY.
  • When OPENMAIC_SECRET_KEY is unset, the server creates a random secret on first use in data/instance-secret.key (inside the openmaic-data volume with Docker Compose) and uses it from then on.

Keep the secret with the database backups: keys saved under a lost or changed secret cannot be read and have to be entered again (the model settings mark them). Set OPENMAIC_SECRET_KEY explicitly when more than one instance shares the database, or when the server has no persistent data directory (a container without the data volume); any string works, for example the output of openssl rand -base64 32.

Without it, a data directory that does not survive a restart (a container without a volume, an ephemeral disk) means a new secret on every start, and several replicas each generate their own; on a read-only working directory no secret can be created and saving a key in the model settings fails. At startup the server warns when OPENMAIC_SECRET_KEY is unset and the data directory is not writable, and when the database holds keys sealed under a different secret than the current one (for example because data/instance-secret.key was lost); it starts anyway.

bash
OPENMAIC_SECRET_KEY=...        # instance secret for keys saved in the model settings
OPENMAIC_CONFIG=/etc/openmaic/openmaic.yml   # optional: another path for openmaic.yml

What workspaces may add

Providers from openmaic.yml can be anything the schema accepts. Providers a workspace adds in the model settings are more restricted, because their endpoint and key are typed by a user of the web app:

  • Amazon Bedrock is not offered: it would sign with the server's own AWS credentials.
  • No proxy, and no key pairs (credentials): presets that authenticate with a key pair, such as AliDocMind, are deployment-only.
  • A custom baseUrl is allowed only for presets that offer chat, and a provider with one serves chat slots only. The URL must not contain credentials, a query string or a fragment, and must resolve to a public address unless the server sets ALLOW_LOCAL_NETWORKS=true. Requests to it do not follow redirects.
  • Local model servers (Ollama, Lemonade) must name their own endpoint rather than use the preset's localhost default.
  • Self-hosted media, search and document services (VoxCPM2, FunASR, Lemonade speech and images, ComfyUI, SearXNG, self-hosted MinerU) run on the server's own network and are deployment-only.
  • Providers the operator switched off with a legacy <CAP>_<VENDOR>_ENABLED=false variable are not offered.
  • A workspace cannot declare a provider id that openmaic.yml already declares.

A deployment that wants users to reach internal services declares those providers in openmaic.yml.

API clients and deprecated request fields

Generation requests no longer need to carry keys or model choices: browserless API calls such as /api/generate-classroom resolve through the same slots as the web app, for the workspace of the caller.

The older request headers x-model, x-api-key, x-base-url, x-provider-type, x-model-routes, x-image-provider, x-image-model, x-video-provider and x-video-model, and the equivalent body fields of the speech, search and document routes, are deprecated. They are honoured only while the slot they would affect has nothing assigned or, on a server still configured through the legacy variables, only a default those translate to (as DEFAULT_MODEL always ranked below the model a request named); a default in openmaic.yml, the workspace's own choice and a locked slot win, and a slot set to null refuses the request whatever it names. With allowUserKeys: false they are ignored altogether. The server logs a warning the first time a request uses them. They will be removed two minor versions after the model settings described above ship.

Migrating from the legacy configuration

When openmaic.yml is absent, the server translates the legacy configuration at startup and logs a deprecation notice, plus a notice for each entry that could not carry over:

Legacy setting Becomes
Provider variables (OPENAI_API_KEY, TTS_MINIMAX_API_KEY, …) and server-providers.yml Deployment providers (read-only in the model settings). The provider id is the preset id, which is the legacy provider id except for web search MiniMax (minimax-search), web search Doubao (doubao-search) and Lemonade images (lemonade-image)
_MODELS The provider's models
DEFAULT_MODEL The llm assignment, as a default a workspace can override (not locked). The agent slot stays off, as before
MODEL_FALLBACK The fallback of DEFAULT_MODEL. Without DEFAULT_MODEL it is dropped with a notice
The first configured TTS, ASR, image, video and document provider, and web search in the old priority order The default assignment of that capability (DEFAULT_IMAGE_PROVIDER picks the image provider)
MODEL_ROUTES Not translated. The server refuses to start (see below)
<CAP>_<VENDOR>_ENABLED=false Still honoured: the provider stays off whatever assigns it

When openmaic.yml exists, the provider variables, server-providers.yml, DEFAULT_MODEL, MODEL_FALLBACK and MODEL_ROUTES are ignored for model resolution, with a notice. <CAP>_<VENDOR>_ENABLED=false still applies; to turn a capability off, set its slot to null instead.

Environment variables. Before:

bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=openai:gpt-5.5
MODEL_FALLBACK=anthropic:claude-sonnet-4-6
TTS_MINIMAX_API_KEY=...
TAVILY_API_KEY=tvly-...

After (the keys stay in the environment):

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  minimax-tts:
    preset: minimax-tts
    apiKey: ${TTS_MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}

slots:
  llm:
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
  tts: minimax-tts
  webSearch: tavily

Remove DEFAULT_MODEL and MODEL_FALLBACK afterwards. The provider variables can stay as the source of the ${VAR} references. A _BASE_URL becomes the provider's baseUrl (OPENAI_BASE_URL → baseUrl under openai; write the URL, or ${OPENAI_BASE_URL} while the variable stays set), and _MODELS becomes its models list. As with the translated DEFAULT_MODEL, the llm written here is a server default workspaces may change; add lock: [llm] to fix it for everyone. Unlike the translation, agent now follows llm: write agent: null to keep the Pro agent off.

server-providers.yml. Before:

yaml title="server-providers.yml"
providers:
  azure:
    apiKey: ...
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
pdf:
  alidocmind:
    accessKeyId: ...
    accessKeySecret: ...
web-search:
  searxng:
    baseUrl: http://searxng:8080

After:

yaml title="openmaic.yml"
providers:
  azure:
    preset: azure
    apiKey: ${AZURE_OPENAI_API_KEY}
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
  alidocmind:
    preset: alidocmind
    credentials:
      accessKeyId: ${ALIDOCMIND_ACCESS_KEY_ID}
      accessKeySecret: ${ALIDOCMIND_ACCESS_KEY_SECRET}
  searxng:
    preset: searxng
    baseUrl: http://searxng:8080

slots:
  llm: azure:my-deployment
  document: alidocmind
  webSearch: searxng

Each section of server-providers.yml becomes providers with the matching preset (providers → chat presets, tts, asr, pdf → document, image, video, web-search), and accessKeyId/accessKeySecret move under credentials.

MODEL_ROUTES. A server that sets MODEL_ROUTES without openmaic.yml does not start:

text
MODEL_ROUTES does not carry over to the model configuration: write the per-stage models as slots in openmaic.yml (or the file named by OPENMAIC_CONFIG) and remove MODEL_ROUTES

Per-stage routes do not map one to one onto slots, so they are not translated. Rewrite each route as the slot its stage belongs to:

MODEL_ROUTES stage Slot
web-search-query-rewrite course.research
scene-outlines-stream course.outline
agent-profiles course.agents
scene-content course.content
scene-content:slide, :quiz, :interactive, :pbl course.content.slide, .quiz, .interactive, .pbl
scene-actions course.actions
chat-adapter, quiz-grade, pbl-v2-runtime and its :instructor, :open-task, :evaluate, :simulator variants classroom
generate-classroom llm
maic-agent-driver agent
conversation-title agent.title
pbl-chat, maic-agent None: these stages were removed. Drop them

Several stages share a slot: give the slot the one model they should use. A route's thinking moves to the slot's thinking, and a maic-agent-driver route's api and contextWindow move to the agent slot. Before:

bash
DEFAULT_MODEL=openai:gpt-5.5
MODEL_ROUTES='{"scene-outlines-stream":"anthropic:claude-sonnet-4-6","scene-content:slide":{"model":"openai:gpt-5.4","thinking":{"effort":"low"}},"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'

After:

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}

slots:
  llm: openai:gpt-5.5
  course.outline: anthropic:claude-sonnet-4-6
  course.content.slide:
    model: openai:gpt-5.4
    thinking: { effort: low }
  agent:
    model: openai:gpt-5.5
    api: openai-completions

Once openmaic.yml exists, the provider variables configure nothing on their own: a TTS, image, document or web search provider that had only its variables (TTS_MINIMAX_API_KEY, IMAGE_SEEDREAM_API_KEY, PDF_MINERU_BASE_URL, TAVILY_API_KEY, …) is no longer used, and other capabilities get no default provider. Declare each provider you use under providers and assign tts, image, document, webSearch (and asr, video) in slots, as in the environment-variable example above; a slot for another capability left unassigned stays unconfigured unless a workspace assigns it in the model settings.

Then remove MODEL_ROUTES, DEFAULT_MODEL and MODEL_FALLBACK.

Docker. Mount the file next to the app and keep the keys in .env.local:

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

ACCESS_CODE — site-wide password

For shared deployments such as internal demos or classrooms, set an access code so visitors must enter a password before they can see the app:

bash
ACCESS_CODE=your-secret-code

Use a long random value (at least 16 characters) — this code is the only secret guarding the deployment. The code is remembered in a signed token stored in an HTTP-only cookie for 7 days; the lifetime is enforced server-side, so visitors re-verify after it expires. Verification is rate limited only when TRUST_PROXY_HEADERS=true is set behind a trusted proxy that overwrites x-forwarded-for / x-real-ip: each client gets 10 attempts per 60 seconds, and a successful verification clears that client's counter. Without a trusted proxy the app cannot attribute a request to a client, so there is no throttle; rely on the length and randomness of the code. Leave it empty to disable access control.

Feature flags

Feature flags accept true or 1; unless noted otherwise, all other values are treated as disabled. NEXT_PUBLIC_* flags are injected into the client at build time, so changing them requires a rebuild:

bash
# MAIC Editor (Pro mode)
NEXT_PUBLIC_MAIC_EDITOR_ENABLED=true

# PPT and Interactive courseware references in Pi playback (build-time; editor references are separate)
NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED=true

# Vocational education task engine (server-side flag)
OPENMAIC_ENABLE_VOCATIONAL=true

# Show the vocational education experimental switch
NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI=true

# Show the video export entry point
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true

# Show the experimental PPTX import entry point
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true

Classroom chat always runs on Pi, which requires a model with tool (function) calling: select one for Classroom in Course Model Config, or set the classroom slot (or the llm slot it inherits from) in openmaic.yml. For Gemini, use the native google preset rather than Google's OpenAI-compatible endpoint. It refuses a model that the built-in catalogue lists without tool calling before inference, with 400 MODEL_CONFIG_INVALID (reason: tool_calling_required), and Course Model Config notes the requirement on the classroom slot.

The old director-graph classroom chat has been removed, and NEXT_PUBLIC_PI_CHAT_ENABLED no longer selects a chat path. Remove it from your configuration: a production build accepts only unset, an empty string, true or 1 (exact match) and fails with migration guidance on any other value. With Docker Compose, the service env_file (.env.local) supplies runtime values, not build args.

PPTX import is still experimental, and its parsed output is not yet fully integrated into the classroom data flow. All NEXT_PUBLIC_* variables above are build-time flags. Docker deployments must also pass them as build args; setting them only as container runtime environment variables is not sufficient.

Other server options

The following server options can also be configured with environment variables:

bash
# Parallel scene-content generation; 0 or unset means sequential
PARALLEL_SCENE_CONCURRENCY=3

# Allow localhost and private-network addresses; self-hosted/private deployments only
ALLOW_LOCAL_NETWORKS=true

# Optional MP4 rendering service
RENDER_SERVICE_URL=http://render-service:9000

# Logging and reasoning
LOG_LEVEL=info
LOG_FORMAT=pretty
LLM_THINKING_DISABLED=false

Legacy provider environment variables

These variables and server-providers.yml are the configuration of earlier releases. They still work when there is no openmaic.yml (see Migrating from the legacy configuration) and are deprecated: they will be removed two minor versions after the model settings ship. With openmaic.yml, use them only as the source of ${VAR} references. DEFAULT_MODEL and MODEL_FALLBACK are covered in the migration section; MODEL_ROUTES is no longer read.

LLM providers

Cloud providers generally use three environment variables: an API key, a base URL, and a model list. The API key is usually required; the base URL and model list are optional. Azure OpenAI requires a resource endpoint, while Ollama and Lemonade do not require an API key.

bash
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=                # optional base URL override
OPENAI_MODELS=                  # optional model allowlist (comma-separated)

Supported provider prefixes:

Prefix Provider
OPENAI_ OpenAI
AZURE_OPENAI_ Azure OpenAI
ANTHROPIC_ Anthropic
GOOGLE_ Google Gemini
DEEPSEEK_ DeepSeek
QWEN_ Alibaba Qwen
KIMI_ Moonshot Kimi
MINIMAX_ MiniMax (Anthropic-compatible endpoint by default)
GLM_ Zhipu GLM
SILICONFLOW_ SiliconFlow
DOUBAO_ Doubao (ByteDance)
OPENROUTER_ OpenRouter
GROK_ xAI Grok
TENCENT_ Tencent Hunyuan
TENCENT_HUNYUAN_ Tencent Hunyuan (alias)
XIAOMI_ Xiaomi MiMo
MIMO_ Xiaomi MiMo (alias)
OLLAMA_ Ollama (local)
LEMONADE_ Lemonade (local)

Azure OpenAI uses the deployment name as the model ID:

bash
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=your-deployment-name

To connect another OpenAI-compatible LLM service, declare an openai-compatible provider with its baseUrl and models in openmaic.yml, or add one with Add provider under Settings → Model Services → LLM.

For Gemini classroom interaction, use the native google preset: add Google in Settings → Model Services and select it for Classroom in Course Model Config. Pi rejects Google’s official OpenAI-compatible endpoint (https://generativelanguage.googleapis.com/v1beta/openai) with migration guidance; compatible tool-calling support is tracked in #1556. This check recognizes only that official endpoint and cannot identify the upstream behind arbitrary third-party gateways.

Local models (Ollama and Lemonade)

Ollama and Lemonade do not require an API key. Put the local service base URL in the server configuration so it passes SSRF validation:

bash
OLLAMA_BASE_URL=http://localhost:11434/v1
# LEMONADE_BASE_URL=http://localhost:13305/v1

To limit available models, use OLLAMA_MODELS or LEMONADE_MODELS as a model allowlist.

TTS providers

Server-side TTS providers use TTS__API_KEY and optionally TTS__BASE_URL.

bash
# Doubao TTS (Volcengine Seed-TTS, native MP3)
TTS_DOUBAO_API_KEY=appId:accessKey
TTS_DOUBAO_BASE_URL=       # optional override

# Qwen TTS
TTS_QWEN_API_KEY=
TTS_QWEN_BASE_URL=         # optional override

# Any OpenAI-compatible TTS endpoint
TTS_OPENAI_API_KEY=
TTS_OPENAI_BASE_URL=

# VoxCPM2 (self-hosted TTS with voice cloning; see the dedicated VoxCPM2 page)
TTS_VOXCPM_BASE_URL=http://localhost:8000

Supported TTS prefixes include TTS_OPENAI_, TTS_AZURE_, TTS_GLM_, TTS_QWEN_, TTS_MINIMAX_, TTS_DOUBAO_, TTS_ELEVENLABS_, TTS_GOOGLE_, TTS_VOXCPM_, and TTS_LEMONADE_. Local Lemonade TTS and VoxCPM2 do not require an API key. Browser-native TTS requires no server configuration.

Administrators can force-disable a TTS provider on the server with TTS__ENABLED=false. See the dedicated VoxCPM2 section for self-hosted TTS and voice cloning.

To use another OpenAI-compatible TTS endpoint, declare an openai-tts provider with that baseUrl in openmaic.yml and assign the tts slot to it. Workspaces cannot add custom TTS endpoints in the model settings.

ASR (speech to text)

bash
# OpenAI Whisper
ASR_OPENAI_API_KEY=
ASR_OPENAI_BASE_URL=       # optional override

# Qwen ASR
ASR_QWEN_API_KEY=
ASR_QWEN_BASE_URL=         # optional override

# Azure ASR
ASR_AZURE_API_KEY=
ASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com

# FunASR (local, no key required)
ASR_FUNASR_BASE_URL=http://localhost:8000/v1

# Lemonade ASR (local, no key required)
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1

Start the OpenAI-compatible FunASR service with funasr-server --device cuda. It accepts WAV uploads at /v1/audio/transcriptions and provides SenseVoiceSmall, Paraformer, and Fun-ASR-Nano through the model selector.

Browser-native ASR requires no server configuration.

To use another OpenAI-compatible ASR endpoint, declare it in openmaic.yml with the openai-whisper preset and its baseUrl, and assign the asr slot to it. Workspaces cannot add custom ASR endpoints in the model settings:

yaml title="openmaic.yml"
providers:
  my-asr:
    preset: openai-whisper
    apiKey: ${MY_ASR_API_KEY}
    baseUrl: https://asr.example.com/v1

slots:
  asr: my-asr:whisper-1

Image generation providers

Image generation providers use IMAGE__API_KEY and optionally IMAGE__BASE_URL. Supported prefixes include:

IMAGE_OPENAI_, IMAGE_SEEDREAM_, IMAGE_QWEN_IMAGE_, IMAGE_NANO_BANANA_, IMAGE_MINIMAX_, IMAGE_GROK_, IMAGE_GLM_, and IMAGE_LEMONADE_.

GLM Image (Zhipu CogView) generates images with CogView models (cogview-3-flash, cogview-4):

yaml title="openmaic.yml"
providers:
  glm-image-service:
    preset: glm-image
    apiKey: ${IMAGE_GLM_API_KEY}
    # Optional:
    baseUrl: https://open.bigmodel.cn/api/paas/v4

slots:
  image: glm-image-service:cogview-3-flash

Or configure via environment variables:

bash
IMAGE_GLM_API_KEY=
# Optional:
IMAGE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

Lemonade is a local service and does not require a key:

bash
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1

ComfyUI Image does not require an API key. It runs on your own network, so it is configured by the deployment: declare it in openmaic.yml with the address of your ComfyUI server and assign the image slot to it.

yaml title="openmaic.yml"
providers:
  comfyui:
    preset: comfyui-image
    baseUrl: http://host.docker.internal:8188

slots:
  image: comfyui

Place workflow JSON exported in API format in OpenMAIC's public/ directory. Use filenames matching comfyui-*.json or containing workflow; OpenMAIC discovers these files automatically and offers them as workflows. With Docker, add the workflow before building the image or mount an individual workflow file into /app/public/ in the container. Because the provider is declared by the deployment, a ComfyUI server on the host or a private network does not need ALLOW_LOCAL_NETWORKS.

Video generation providers

Video generation providers use VIDEO__API_KEY and optionally VIDEO__BASE_URL. Supported prefixes include:

VIDEO_SEEDANCE_, VIDEO_KLING_, VIDEO_SORA_, VIDEO_MINIMAX_, VIDEO_GROK_, and VIDEO_HAPPYHORSE_.

Document and media parsing

The exact course-material formats depend on the selected parser. OpenMAIC currently supports text, PDF, Office documents, images, and some audio/video formats; supported formats and capabilities vary by provider.

bash
# MinerU self-hosted
PDF_MINERU_BASE_URL=http://localhost:8888

# Optional backend for self-hosted MinerU
PDF_MINERU_BACKEND=pipeline

# MinerU Cloud
PDF_MINERU_CLOUD_API_KEY=
PDF_MINERU_CLOUD_BASE_URL=https://mineru.net/api/v4

# AliDocMind (uses Alibaba Cloud AccessKey, not a separate API key)
ALIDOCMIND_ACCESS_KEY_ID=
ALIDOCMIND_ACCESS_KEY_SECRET=
ALIDOCMIND_BASE_URL=       # optional override

unpdf is built into OpenMAIC and supports basic PDF parsing. For Office documents, images, and material requiring OCR, table, formula, or layout analysis, select a compatible MinerU or AliDocMind provider.

AliDocMind document parsing supports PDF, DOCX, PPTX, XLSX, and PNG, JPG/JPEG, BMP, and GIF. Audio and video material is currently supported only through AliDocMind: MP4, MOV, AVI, MKV, and WMV for video; MP3, WAV, and AAC for audio. M4A is not supported.

Configure Tavily, Exa, Firecrawl, Bocha, Brave, Baidu, SearXNG, or MiniMax:

bash
TAVILY_API_KEY=
TAVILY_BASE_URL=           # optional override

EXA_API_KEY=
EXA_BASE_URL=https://api.exa.ai  # optional override

FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev  # optional override

BOCHA_API_KEY=
BOCHA_BASE_URL=            # optional override

BAIDU_API_KEY=
BAIDU_BASE_URL=https://qianfan.baidubce.com  # optional override

# Self-hosted SearXNG, no API key required
SEARXNG_BASE_URL=

WEB_SEARCH_MINIMAX_API_KEY=
WEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com  # optional override

Brave and SearXNG do not require an API key. Web search has no per-generation switch: it runs whenever the workspace's webSearch slot resolves, and turning it off (webSearch: null in openmaic.yml, or with the search switch in Settings → Course Model Config) affects later generations of that workspace. Grok web search is provided through the Grok LLM search tool, not a standalone web-search provider.

server-providers.yml

Deprecated in favour of openmaic.yml. When both exist, only openmaic.yml configures models.

In addition to environment variables, place configuration for providers registered in the code in server-providers.yml at the project root. The server loads it at startup, and its structure follows the environment variable categories:

yaml title="server-providers.yml"
providers:
  openai:
    apiKey: sk-...
    baseUrl: https://api.openai.com/v1
    models:
      - gpt-5.5
  anthropic:
    apiKey: sk-ant-...

tts:
  doubao-tts:
    apiKey: appId:accessKey

asr:
  openai-whisper:
    apiKey: sk-...

pdf:
  mineru:
    baseUrl: http://localhost:8888
  alidocmind:
    accessKeyId: your-access-key-id
    accessKeySecret: your-access-key-secret

image:
  seedream:
    apiKey: ...

video:
  seedance:
    apiKey: ...

web-search:
  tavily:
    apiKey: tvly-...
  exa:
    apiKey: ...
  firecrawl:
    apiKey: fc-...
  searxng:
    baseUrl: http://localhost:8080

Environment variables override matching YAML provider fields one by one. Keys must use provider IDs registered in the code, such as openai, doubao-tts, openai-whisper, mineru, alidocmind, seedream, seedance, exa, tavily, or searxng. Unknown IDs cannot register custom LLM providers here.

Model authorization for server-managed providers

For providers that use deployment credentials to send a selected model upstream, set an explicit models list in openmaic.yml (providers..models), or the corresponding *_MODELS environment variable / server-providers.yml section's models list. This applies to chat, image, video, text to speech, speech recognition and model-based web search. Model ids match exactly, including case, after surrounding whitespace is removed at configuration load. Deterministic adapter aliases use the declared model id for authorization. Requests for unlisted models return HTTP 403 with PROVIDER_DISABLED and the message “This model is not authorized for server-managed credentials”.

Breaking change: without an explicit list, only deployment default/fallback models and the primary/fallback models referenced by slots for that capability are usable. DEFAULT_MODEL and MODEL_FALLBACK grant chat models on legacy deployments. A provider-only media slot grants its single adapter default model; it does not grant the preset's catalogue. Workspace assignments cannot expand the deployment's authorized set. Voice registration and synthesis must also authorize any model selected for voice compatibility.

To migrate, list every model users should be able to select, restart the server, and update any workspace selections that are no longer authorized. Startup logs name the setting to add for each provider that has credentials but no list. Users' own provider credentials and keyless local providers such as Ollama and Lemonade are unaffected. A provider configured without credentials is not restricted, so do not point one at a gateway that adds its own upstream credential (for example an internal OpenAI-compatible proxy): give that gateway a key and configure it as a credentialed provider with a models list, or restrict models in the gateway itself. Document parsers and search services without model selection (such as Tavily) need no model list.

yaml
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
    models: [gpt-5.4-mini]
slots:
  llm: openai:gpt-5.4-mini

Legacy equivalent: OPENAI_API_KEY=... and OPENAI_MODELS=gpt-5.4-mini. For media, use the capability prefix, for example IMAGE_SEEDREAM_MODELS, VIDEO_SEEDANCE_MODELS, TTS_OPENAI_MODELS, ASR_OPENAI_MODELS and WEB_SEARCH_CLAUDE_MODELS.

DeepSeek web search and fetch

The deepseek preset shares DEEPSEEK_API_KEY and the API base across chat, web search and hosted Markdown extraction. Search uses POST /v1/tools/search, returns up to 50 results, truncates queries to 128 Unicode code points, and uses summary with snippet as a fallback. Fetch uses POST /v1/tools/fetch and reads content and an optional nonempty title.

In legacy configuration, opt in with WEB_SEARCH_DEEPSEEK_ENABLED=true or an explicit web-search.deepseek entry in server-providers.yml. Chat credentials alone never enable search or replace another search default. WEB_SEARCH_DEEPSEEK_ENABLED=false force-disables search. When openmaic.yml exists, the legacy variable cannot assign a slot: explicitly declare the provider and assign webSearch and/or webFetch.

The default API base is https://api.deepseek.com/v1. On api.deepseek.com, every base path (including /beta) is normalized to /v1 before appending /tools/search or /tools/fetch. Custom gateway bases preserve their configured path prefix; a bare root gets /v1 first. Custom endpoints belong in deployment configuration, while client and workspace overrides remain restricted to official endpoints. The default fetch reader remains builtin. See DeepSeek official pricing for current request charges. Failed requests and searches with no results are not billed.

yaml
providers:
  deepseek:
    preset: deepseek
    apiKey: ${DEEPSEEK_API_KEY}
    baseUrl: https://api.deepseek.com/v1
slots:
  webSearch: deepseek
  webFetch: deepseek

Declare one Kimi account and assign both slots to it:

yaml
providers:
  kimi:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    # Optional: https://api.moonshot.ai/v1 for the international Open Platform.
    # baseUrl: https://api.moonshot.ai/v1
slots:
  llm: kimi:kimi-k3
  webSearch: kimi

For a Coding Plan key, use preset: kimi-coding-plan and llm: kimi:kimi-for-coding; its default base is https://api.kimi.com/coding/v1. The Open Platform and Coding Plan keys must be paired with their matching base URL. In legacy configuration, KIMI_API_KEY and optional KIMI_BASE_URL supply search credentials only after explicit opt-in: set WEB_SEARCH_KIMI_ENABLED=true or add web-search.kimi in server-providers.yml (for example, with enabled: true). Chat credentials alone do not enable search or change the default search provider. WEB_SEARCH_KIMI_ENABLED=false disables Kimi search even when a slot assigns it. WEB_SEARCH_KIMI_ENABLED is the only Kimi search switch. KIMI_MODELS applies only to chat. Conflicting legacy chat and search accounts produce a warning and the search entry is skipped; declare separate provider ids in openmaic.yml when separate accounts are needed. When openmaic.yml exists, declare the provider and assign the slots explicitly. Settings and the first-run wizard offer both capabilities through the same account.

Endpoint selection follows the base path: after removing trailing slashes, a path ending in /coding/v1 uses /search; otherwise it uses /tools/search_pro. Custom proxies configured by the deployment must preserve that path layout. The wizard recommends search only for official account bases, so a custom workspace chat proxy does not automatically acquire a search assignment.

When openmaic.yml is present, declare providers.: { preset: kimi } and assign slots.webSearch: ; the legacy WEB_SEARCH_KIMI_ENABLED variable cannot assign that slot.

Web fetch provider slot

webFetch selects the reader used by the agent's fetch_url tool. It defaults to builtin in legacy, empty and YAML configurations, including an omitted root under lock: all. Explicit webFetch: null disables fetching and removes the tool and its prompt from subsequent runs. Each request uses one provider, with no fallback or escalation. An invalid provider reference or a workspace endpoint rejected by policy fails the entire agent run during capability resolution, before the agent starts, as with webSearch. This is a configuration error, not a failed fetch_url result; it does not switch to the builtin reader.

Provider Credentials Extraction
builtin None Local Readability/Turndown reader; PDFs use the document slot
kimi Existing Kimi provider key and base URL Hosted Markdown extraction, including PDF URLs
deepseek Existing DeepSeek chat key and API base Hosted Markdown extraction
firecrawl Firecrawl API key (preset: firecrawl) Hosted Markdown extraction of the page's main content, including PDF URLs
yaml
providers:
  reader:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    baseUrl: https://api.moonshot.cn/v1
slots:
  webFetch: reader

Both kimi and kimi-coding-plan presets offer webFetch. For Coding Plan credentials, use preset: kimi-coding-plan; its default base is https://api.kimi.com/coding/v1. Any explicitly configured base URL is preserved. Moonshot Open Platform bases (https://api.moonshot.cn/v1 and https://api.moonshot.ai/v1) use /tools/fetch; Coding Plan uses /fetch. Both send POST with { "url": ... } and Bearer authentication. The adapter accepts markdown or content. A maintainer live-tested POST https://api.kimi.com/coding/v1/fetch with a real key and confirmed { markdown, title, url }; international endpoint availability remains unverified. Requests respect the provider's proxy setting and standard proxy environment variables.

DeepSeek also offers hosted Markdown extraction through the deepseek preset, reusing the chat key and API base. Assign webFetch explicitly as shown above. It uses /v1/tools/fetch; an explicit deployment gateway base is preserved. It retains the same transport deadlines and single transport-error retry as Kimi, without retrying HTTP errors or invalid responses.

In Settings → Model Services → Web Fetch, use the existing provider credentials. The key is shared with that provider's chat service: editing or removing it also affects chat. Choose the reader in Course Model Config → Web Fetch. Administrator locks make the card read-only. First-run setup keeps the builtin default until a reader is explicitly selected. Workspace credentials can use official API bases; arbitrary fetch endpoints and proxies must be declared by the administrator in openmaic.yml.

Unlike kimi, firecrawl has its own key: declare a provider with preset: firecrawl and apiKey (for example ${FIRECRAWL_API_KEY}), or enter the key in Settings → Model Services → Web Fetch. It sends POST /v2/scrape with Bearer authentication and asks for the page's main content as Markdown. The default base is https://api.firecrawl.dev; a self-hosted Firecrawl base must be declared by the administrator in openmaic.yml.

The same firecrawl provider also serves web search (POST /v2/search): one key and base URL cover both, so a single provider can be assigned to slots.webSearch and slots.webFetch. The legacy FIRECRAWL_API_KEY and FIRECRAWL_BASE_URL variables configure that provider; web fetch stays on builtin until webFetch is assigned to it.

The session URL gate, strict URL normalization and all local DNS address checks run before forwarding a URL to a hosted reader. The same checks run against a reported final URL before accepting its content. Hosted readers control their own DNS and intermediate redirects; local checks cannot pin their networks. Their text remains untrusted, with challenge-page detection, byte limits, a truncation notice and the existing web material persistence and {trusted, untrusted} result.


Packages Docs Content Docs Configuration.Ru (`packages/docs/content/docs/configuration.ru.mdx`)


title: Конфигурация

У OpenMAIC три источника конфигурации, и у каждого одна задача:

Источник Кто пишет Что содержит Когда применяется
openmaic.yml Администратор Провайдеры, назначения сервера по умолчанию, закреплённые слоты и то, могут ли пользователи добавлять свои ключи При запуске (после изменения нужен перезапуск)
Настройки моделей в веб-приложении Пользователи рабочего пространства Их собственные провайдеры и назначения для всех слотов, которые openmaic.yml не закрепляет; хранятся в базе данных Сразу
Переменные окружения (.env.local, секрет контейнера) Администратор Секреты, на которые ссылается openmaic.yml, и все остальные серверные опции При запуске

DATABASE_URL is required; other environment variables are optional (see Deployment). Providers that use server credentials for model selection also need authorized model lists, as described below. See .env.example for environment variables, openmaic.example.yml for a sample model configuration, and Supported models for preset and model IDs.

В прежних версиях модели настраивались только через переменные окружения провайдеров, server-providers.yml, DEFAULT_MODEL и MODEL_ROUTES. Кроме MODEL_ROUTES, всё это пока работает, но устарело; см. Миграция со старой конфигурации.

Модели и провайдеры

Основные понятия

  • Провайдер (provider) — учётная запись, к которой может обращаться сервер: ключ OpenAI, Token Plan MiniMax, собственный OpenAI-совместимый сервер и т. п. У каждого провайдера есть выбранный вами id и пресет (preset), который определяет вендора и покрываемые возможности.
  • Слот (slot) — место в продукте, где используется AI: план курса, содержимое слайдов, синтез речи, веб-поиск и т. д. Слоты образуют по одному дереву на каждую возможность; слот без назначения следует за родителем.
  • Назначение (assignment) — какой провайдер и какая модель используются в слоте (provider:model), при необходимости с параметрами вызова и резервной моделью, или null, чтобы выключить слот.

Возможность доступна ровно тогда, когда её слот разрешается в назначение. Отдельных переключателей нет.

openmaic.yml

Сервер читает openmaic.yml из каталога, в котором он запущен (корень репозитория; /app в Docker-образе). Чтобы использовать другой путь, задайте OPENMAIC_CONFIG; относительный путь разрешается от того же каталога, и файл в этом случае обязан существовать. Без файла сервер использует настройки веб-приложения и, пока они заданы, устаревшие переменные.

Файл читается один раз при запуске: после изменения перезапустите сервер. Перед запуском файл проверяется целиком. Незаданная ${VAR}, неизвестный пресет или слот, ссылка на необъявленного провайдера или провайдер без нужной слоту возможности останавливают сервер, и каждая проблема выводится с указанием места:

text
[boot] Invalid server configuration; the server will not start: Invalid model configuration in /app/openmaic.yml:
  - providers.openai.apiKey: environment variable OPENAI_API_KEY is not set
  - slots.course.outlines: unknown slot

Не записывайте секреты в сам файл: ссылайтесь на них через ${VAR} и задавайте переменную в окружении. Значения из переменных никогда не выводятся в этих сообщениях.

Полный пример

text
/* Detailed source-code truncated for AI context efficiency. */

С этим файлом слоты, записанные в slots, — значения сервера по умолчанию: пользователи могут изменить каждый из них в настройках моделей, кроме tts, который lock закрепляет для всех. Не указанный в файле чат-слот, например course.agents, следует за родителем (llm); не указанный корень возможности, например asr или document, остаётся ненастроенным, пока его не назначит пользователь. При allowUserKeys: false пользователи выбирают только среди объявленных здесь провайдеров.

Провайдеры

Каждая запись в providers объявляет одного провайдера. Ключ — его id, по которому на него ссылаются слоты: от 1 до 63 строчных букв, цифр и дефисов, первым символом должна быть буква или цифра.

Поле Обязательно Назначение
preset Да Встроенный пресет провайдера. См. Поддерживаемые модели → Пресеты.
apiKey Обычно API-ключ. Сервисы без ключа (локальные серверы, Brave, SearXNG, unpdf) его не указывают.
baseUrl Для некоторых пресетов Адрес вместо собственного адреса пресета. Обязателен для openai-compatible, azure, searxng и mineru.
models For server-managed credentials Authorized model ids. Deployment slot defaults/fallbacks for the capability are also authorized; catalogues and workspace assignments do not grant additional models.
proxy Нет URL HTTP-прокси для запросов этого провайдера.
credentials Для некоторых пресетов Пара ключей для сервисов, которые не используют один ключ. AliDocMind принимает accessKeyId и accessKeySecret.
options Нет Несекретные настройки, специфичные для провайдера, например backend для voxcpm-tts. Имена состоят из букв, цифр, _ и -; значения — строки, числа или булевы значения (без вложенных словарей и списков), ${VAR} поддерживается. Настройки моделей показывают options, поэтому никогда не храните в них ключи. Только для развёртывания: настройки моделей не могут их задавать.

Большинство пресетов покрывают одну возможность и соответствуют встроенным реестрам: пресет openai — только чат, а изображения, синтез и распознавание речи OpenAI — это пресеты openai-image, openai-tts и openai-whisper. Объявляйте по одному провайдеру на возможность и ссылайтесь на одну и ту же ${VAR}, если один ключ покрывает несколько возможностей. Пресеты Token Plan (minimax, tokendance, volcengine-ark, kimi-coding-plan) покрывают несколько возможностей одним ключом, каждую — по собственному адресу тарифа. baseUrl у провайдера заменяет адрес для всех возможностей, которые он покрывает, поэтому задавайте baseUrl для Token Plan, только если вместе с ним переезжают все его возможности.

Amazon Bedrock (preset: bedrock) подписывает запросы цепочкой учётных данных AWS на сервере (окружение, профиль или роль) и берёт регион из BEDROCK_REGION, AWS_REGION или AWS_DEFAULT_REGION; apiKey — необязательный bearer-токен.

Слоты

Слот Возможность Родитель За что отвечает
llm chat — Чат-модель по умолчанию: все чат-вызовы, не покрытые слотами ниже
course.research chat llm Переформулирование запроса веб-поиска при исследовании курса
course.outline chat llm Планы сцен
course.agents chat llm Профили агентов — преподавателя и одноклассников
course.content chat llm Содержимое сцен для типов без собственного слота
course.content.slide chat course.content Сцены-слайды
course.content.quiz chat course.content Сцены-тесты
course.content.interactive chat course.content Интерактивные сцены
course.content.pbl chat course.content Сцены проектного обучения (PBL)
course.actions chat llm Действия сцен (что агенты говорят и делают в каждой сцене)
classroom chat llm Чат на занятии, проверка тестов и среда выполнения PBL
agent chat llm Агент Pro workbench. Требует вызова инструментов. Также принимает api и contextWindow
agent.title chat agent Заголовки сессий Pro. Настраивается только в openmaic.yml; в настройках моделей не отображается
tts tts — Синтез речи
asr asr — Распознавание речи (голосовой ввод, расшифровка аудио и видео)
image image — Генерация изображений
video video — Генерация видео
webFetch webFetch — Извлечение веб-страниц (по умолчанию builtin)
webSearch webSearch — Веб-поиск
document document — Сервис разбора документов (MinerU, MinerU Cloud, AliDocMind). Встроенному извлечению текста из PDF сервис не нужен

Как разрешается слот:

  • Разрешение начинается со слота вызова и поднимается к корню возможности. Побеждает первое найденное назначение, поэтому назначение родителя служит значением по умолчанию для всего поддерева. Изменение родителя меняет всех потомков без собственного назначения.
  • Внутри закреплённого поддерева (слот или его предок указан в lock, или задано lock: all) учитывается только openmaic.yml: подъём идёт до закреплённого слота, и побеждает первое назначение, записанное в файле. Везде остальном побеждает выбор пользователя: сначала ищется собственное назначение рабочего пространства от слота до корня, и только если его нет, — значения по умолчанию из openmaic.yml, тоже от слота вверх. Поэтому слот, который рабочее пространство не меняло, следует за выбором рабочего пространства на родителе, даже если на нём записано значение сервера по умолчанию: значения сервера — лишь значения по умолчанию.
  • null останавливает подъём: слот и все слоты под ним без собственного назначения выключены.
  • Если до корня назначение не найдено, возможность не настроена. Вызовы завершаются ошибкой с именем слота; перехода к какому-либо вендору нет.
  • Требования принадлежат объявившему их слоту и проверяются на модели, в которую он разрешается, — назначенной или унаследованной. agent требует вызова инструментов; если встроенный каталог говорит, что модель этого не умеет, агент отказывается работать, а не обходится как-нибудь. Модель, которой нет в каталоге, допускается. У agent.title требований нет, так что для него подойдёт модель поменьше.

Назначения

У назначения четыре формы:

yaml
slots:
  llm: openai:gpt-5.5            # провайдер:модель
  webSearch: tavily              # только провайдер: его модель по умолчанию
  course.outline:                # объект с параметрами
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
    thinking: { effort: low }
  video: null                    # выключено

Объектная форма принимает:

Поле Назначение
model Обязательно. провайдер:модель или, вне чат-слотов, только провайдер.
fallback Модель для одного повтора, в той же форме. Только для слотов языковых моделей; см. Резервные модели.
thinking Параметры рассуждения для чат-моделей: enabled (true/false), mode (default, disabled, enabled, auto), effort (none, minimal, low, medium, high, xhigh, max), level (minimal, low, medium, high), budgetTokens, excludeReasoningOutput. Адаптер каждого провайдера применяет то, что поддерживает его API.
api Только agent: транспортный диалект, openai-completions (по умолчанию) или openai-responses.
contextWindow Только agent: размер контекстного окна для сжатия, если он должен быть меньше значения из каталога.

Слот agent не должен задавать thinking.effort: его транспорт с вызовом инструментов не может совмещать силу рассуждения с функциями-инструментами.

Ссылки только на провайдера

Ссылка без :model означает модель провайдера по умолчанию: ту, что Token Plan рекомендует для этой возможности, иначе модель по умолчанию из встроенного реестра. У сервисов поиска и документов чаще всего нет выбора модели, поэтому обычная форма — webSearch: tavily или document: mineru-cloud. Чат-слоты всегда указывают модель; llm: openai отклоняется при запуске.

Резервные модели

Резервные модели применяются только к слотам языковых моделей: llm и всем слотам под ним. fallback в слоте tts, asr, image, video, webSearch, webFetch или document отклоняется — при запуске для openmaic.yml и при сохранении в настройках моделей.

fallback слота используется один раз, если вызов основной модели после её собственных повторов завершился сбоем, допускающим повтор: временная ошибка SDK, таймаут, сетевая ошибка, ограничение частоты запросов (429), нехватка мощности (503) или пустой ответ. Если провайдер явно отказал из-за исчерпанной квоты (OpenAI-совместимый код вроде insufficient_quota, при любом HTTP-статусе), вызов на основной модели не повторяется и сразу переходит на резервную модель. Отказы по соображениям безопасности контента и другие ошибки 4xx к резервной модели не приводят. Резервная модель работает и для обычных, и для потоковых вызовов и использует параметры thinking слота.

Резервная модель — часть назначения и наследуется вместе с ним: course.content.slide без собственного назначения использует модель и резервную модель course.content. Слот с требованием не переходит на резервную модель, которая ему не соответствует.

Как выключить возможность

Запишите null в её слот. video: null выключает генерацию видео по умолчанию, но пользователи могут включить её в настройках моделей; добавьте lock: [video], чтобы она оставалась выключенной для всех. course.content.pbl: null выключает генерацию сцен PBL, а остальная часть course.content продолжает работать. Вызов выключенного слота сразу завершается ошибкой, а медиа-маршруты отвечают 403, не переключаясь на другого вендора.

Значения по умолчанию, закрепления и настройки моделей

То, что openmaic.yml записывает в slots, — значение сервера по умолчанию: пользователи могут изменить его в настройках моделей, где на карточке написано По умолчанию сервера, а Сбросить к значению сервера по умолчанию отменяет их изменение. То, что перечислено в lock, закреплено администратором для всех вместе со всем поддеревом:

yaml
lock: [llm, video]   # эти слоты и все слоты под ними
# lock: all          # все слоты
  • Каждый слот из lock должен быть записан в slots (null тоже считается: video: null вместе с lock: [video] выключает видео для всех). Неизвестный слот или закреплённый слот, не записанный в slots, останавливает сервер при запуске с указанием позиции (lock.1: ...).
  • Закреплённый слот закрепляет и своих потомков: при lock: [llm] ни один пользователь не может задать course.outline или другой слот языковой модели, и все они разрешаются только по openmaic.yml.
  • lock: all закрепляет все слоты, записанные и нет; корень возможности, не записанный в slots, тогда не настроен ни для кого. Однако неуказанный webFetch использует builtin; для отключения задайте webFetch: null.
  • null, значение по умолчанию и закрепление сочетаются естественно: одно video: null выключено, пока пользователь не включит; с lock: [video] остаётся выключенным.

Настройки принимают один из трёх видов в зависимости от конфигурации:

Вид Когда Что показано
Настройте сами allowUserKeys включён (по умолчанию) Token Plan, сервисы моделей и настройка моделей курса
Выберите модель allowUserKeys: false Только настройка моделей курса, выбор среди провайдеров из openmaic.yml
Настроено администратором Все слоты закреплены (lock: all) Схема моделей курса, каждая карточка только для чтения (на ней модель или пометка, что она выключена или не задана)

Один принцип определяет каждый элемент управления: он показан, только если его использование может что-то изменить. Вкладка в сервисах моделей и добавление сервиса в ней появляются, только пока у возможности есть слот, который пользователь может задать; Token Plan появляется, только пока тариф ещё может заполнить незакреплённый слот, и подключение тарифа никогда не трогает закреплённые слоты. Закреплённая карточка в настройке моделей курса — одна строка только для чтения с пометкой Закреплено администратором. Выбор модели на главной странице показан, только когда модель по умолчанию можно изменить (llm не закреплён и какой-либо провайдер предлагает языковую модель).

Настройки моделей в веб-приложении читают и изменяют то же дерево для текущего рабочего пространства:

  • Token Plan: подключение тарифа добавляет его провайдера с ключом и заполняет ещё не назначенные слоты, которые тариф рекомендует. Отключение удаляет провайдера; слоты, которые его использовали, снова следуют родителям.
  • Сервисы моделей: по вкладке на возможность (языковые модели, изображения, видео, синтез речи, распознавание речи, разбор документов, веб-поиск) со списком сервисов. Провайдеры из openmaic.yml показаны только для чтения и без сведений о ключах. Сервис, настроенный рабочим пространством, становится его провайдером: ключ только записывается и отображается лишь маской вида …abcd, собственный адрес возможен только для языковых моделей (Azure Speech использует официальный адрес своего региона, например https://<region>.tts.speech.microsoft.com), а самостоятельно размещённые сервисы и пары ключей настраиваются в openmaic.yml. Кнопки проверки проверяют сохранённого провайдера на сервере.
  • Настройка моделей курса: основная модель — это слот llm, каждый этап конвейера — свой слот (следовать основной модели значит не иметь своего назначения); переключатели изображений, видео, речи, поиска и расшифровки ставят слот в null. Закреплённый слот — одна строка только для чтения, а слот со значением сервера по умолчанию после изменения пользователем предлагает Сбросить к значению сервера по умолчанию.

Настройки провайдеров, сохранённые браузером в прежних версиях (провайдеры с ключами и базовыми URL, выбранная модель, подключение Token Plan и выбор для каждой возможности), один раз импортируются в рабочее пространство. Импорт никогда не заменяет существующие настройки рабочего пространства и слоты, заблокированные openmaic.yml, и пропускает записи, отклонённые allowUserKeys: false или проверкой. После ответа 2xx из браузера удаляется только то, что теперь хранит рабочее пространство: каждая пропущенная сервером запись (собственный адрес медиасервиса, пресет, недоступный рабочему пространству, id провайдера, объявленный развёртыванием, ...) остаётся в этом браузере вместе с ключом, больше никуда не отправляется и показывается с причиной в разделе «Настройки → Сервисы моделей» (уведомление приходит один раз), пока её не настроят заново или не удалят. Провайдер Azure Speech с официальным адресом региона импортируется. После 400 предложение отклонено целиком: оно не отправляется повторно, а его записи вместе с ключами сохраняются в этом браузере и перечисляются вместе с пропущенными. Нечитаемое предложение удаляется; для повторной попытки оно остаётся в браузере только при 401, 404, 409, 5xx или сетевой ошибке. Явно выключенный голосовой ввод становится asr: null, а озвучка, изображения или видео, выключенные при настроенном рабочем провайдере, становятся tts: null, image: null или video: null (слот, заблокированный openmaic.yml, остаётся как есть). Заново вручную нужно настроить: модели по этапам (в настройке моделей курса или в slots файла openmaic.yml), параметры рассуждения (thinking у слота), собственных провайдеров синтеза и распознавания речи и пару ключей AliDocMind (в openmaic.yml; они остаются в браузере и показываются вместе с пропущенными записями), backend VoxCPM (options.backend) и подисточники поиска Baidu (действуют значения сервера по умолчанию). Выключенное исследование не переносится (оно лишь останавливало исследование для курса; чат и агент продолжали искать), как и переключатель, выключенный только по умолчанию: каждая возможность работает, когда её слот разрешается.

Рабочее пространство — это владелец (owner) в смысле документации по хранению данных: в стандартной конфигурации Docker Compose (однопользовательский режим) рабочее пространство одно, а в многопользовательском развёртывании у каждой учётной записи своё.

allowUserKeys

yaml
allowUserKeys: false

allowUserKeys (по умолчанию true) определяет, могут ли пользователи добавлять собственных провайдеров, ключи и тарифы Token Plan. При false пользователи выбирают только среди провайдеров, объявленных в openmaic.yml, и только для незакреплённых слотов: Token Plan и добавление сервисов не показываются, а провайдеры, добавленные рабочими пространствами ранее, больше не используются. Запросы, которые всё ещё указывают свою модель, ключ или адрес (устаревшие заголовки x-model, x-api-key и x-base-url и поля провайдера в медиа-маршрутах), тогда игнорируются, а форма с заголовками у кнопок проверки провайдеров отклоняется. Многопользовательское развёртывание, которое оплачивает всё использование, обычно задаёт его и закрепляет нужные слоты.

Секреты и OPENMAIC_SECRET_KEY

  • Ключи в openmaic.yml берутся из окружения через ${VAR}. Они никогда не сохраняются в базе данных и не отправляются в браузер.
  • Ключи, которые рабочее пространство вводит в настройках моделей, хранятся в базе данных, зашифрованные AES-256-GCM секретом экземпляра OPENMAIC_SECRET_KEY.
  • Если OPENMAIC_SECRET_KEY не задан, сервер при первом использовании создаёт случайный секрет в data/instance-secret.key (в Docker Compose — внутри тома openmaic-data) и дальше использует его.

Храните секрет вместе с резервными копиями базы данных: ключи, сохранённые под утерянным или изменённым секретом, прочитать нельзя, их придётся ввести заново (настройки моделей их отмечают). Задайте OPENMAIC_SECRET_KEY явно, если базу данных разделяют несколько экземпляров, или если у сервера нет постоянного каталога данных (например, контейнер без тома данных); подойдёт любая строка, например вывод openssl rand -base64 32.

Без него каталог данных, который не переживает перезапуск (контейнер без тома, временный диск), означает новый секрет при каждом запуске, а несколько реплик создают каждая свой; в рабочем каталоге только для чтения секрет создать нельзя, и сохранение ключа в настройках моделей завершается ошибкой. При запуске сервер предупреждает, если OPENMAIC_SECRET_KEY не задан и каталог данных недоступен для записи, а также если в базе данных есть ключи, зашифрованные другим секретом, чем текущий (например, потому что data/instance-secret.key потерян); запуск при этом продолжается.

bash
OPENMAIC_SECRET_KEY=...        # секрет экземпляра для ключей из настроек моделей
OPENMAIC_CONFIG=/etc/openmaic/openmaic.yml   # необязательно: другой путь к openmaic.yml

Что могут добавлять рабочие пространства

Провайдеры в openmaic.yml могут быть любыми, что допускает схема. Провайдеры, которых рабочее пространство добавляет в настройках моделей, ограничены сильнее, потому что их адрес и ключ вводит пользователь веб-приложения:

  • Amazon Bedrock не предлагается: он подписывал бы запросы собственными учётными данными AWS сервера.
  • Нельзя задать proxy и пары ключей (credentials): пресеты с аутентификацией парой ключей, такие как AliDocMind, доступны только развёртыванию.
  • Собственный baseUrl разрешён только пресетам с чатом, и такой провайдер обслуживает только чат-слоты. URL не должен содержать учётных данных, строки запроса или фрагмента и должен разрешаться в публичный адрес, если на сервере не задано ALLOW_LOCAL_NETWORKS=true. Запросы к нему не следуют перенаправлениям.
  • Локальные серверы моделей (Ollama, Lemonade) должны указывать собственный адрес, а не localhost по умолчанию из пресета.
  • Собственные медиа-, поисковые и документные сервисы (VoxCPM2, FunASR, речь и изображения Lemonade, ComfyUI, SearXNG, собственный MinerU) работают в сети самого сервера и доступны только развёртыванию.
  • Провайдеры, выключенные администратором через устаревшую переменную <CAP>_<VENDOR>_ENABLED=false, не предлагаются.
  • Рабочее пространство не может объявить id провайдера, уже объявленный в openmaic.yml.

Чтобы дать пользователям доступ к внутренним сервисам, объявите этих провайдеров в openmaic.yml.

API-клиенты и устаревшие поля запросов

Запросам на генерацию больше не нужно передавать ключи или выбор модели: безбраузерные вызовы API, например /api/generate-classroom, разрешаются через те же слоты, что и веб-приложение, для рабочего пространства вызывающего.

Старые заголовки x-model, x-api-key, x-base-url, x-provider-type, x-model-routes, x-image-provider, x-image-model, x-video-provider и x-video-model, а также соответствующие поля тела запроса у маршрутов речи, поиска и документов устарели. Они учитываются, только пока у затрагиваемого слота нет назначения или, на сервере, всё ещё настроенном через устаревшие переменные, есть лишь значение по умолчанию, полученное из них (DEFAULT_MODEL всегда уступал модели, указанной в запросе); значение по умолчанию из openmaic.yml, собственный выбор рабочего пространства и закреплённый слот побеждают, а слот со значением null отклоняет запрос, что бы в нём ни было указано. При allowUserKeys: false они не учитываются вовсе. При первом таком запросе сервер пишет предупреждение в журнал. Их удалят через две минорные версии после выхода описанных выше настроек моделей.

Миграция со старой конфигурации

Если openmaic.yml нет, сервер при запуске преобразует старую конфигурацию и пишет в журнал уведомление об устаревании, а также уведомление по каждой записи, которую не удалось перенести:

Старая настройка Становится
Переменные провайдеров (OPENAI_API_KEY, TTS_MINIMAX_API_KEY, …) и server-providers.yml Провайдерами развёртывания (только для чтения в настройках моделей). Id провайдера — это id пресета, который совпадает со старым id провайдера, кроме веб-поиска MiniMax (minimax-search), веб-поиска Doubao (doubao-search) и изображений Lemonade (lemonade-image)
_MODELS models провайдера
DEFAULT_MODEL Назначением llm в виде значения по умолчанию, которое рабочее пространство может переопределить (не блокируется). Слот agent, как и раньше, остаётся выключенным
MODEL_FALLBACK Резервной моделью DEFAULT_MODEL. Без DEFAULT_MODEL отбрасывается с уведомлением
Первый настроенный провайдер TTS, ASR, изображений, видео и документов, а также веб-поиск в прежнем порядке приоритета Назначением этой возможности по умолчанию (провайдера изображений выбирает DEFAULT_IMAGE_PROVIDER)
MODEL_ROUTES Не преобразуется. Сервер отказывается запускаться (см. ниже)
<CAP>_<VENDOR>_ENABLED=false По-прежнему действует: провайдер остаётся выключенным, кто бы его ни назначил

Если openmaic.yml существует, переменные провайдеров, server-providers.yml, DEFAULT_MODEL, MODEL_FALLBACK и MODEL_ROUTES не участвуют в разрешении моделей, о чём выводится уведомление. <CAP>_<VENDOR>_ENABLED=false продолжает действовать; чтобы выключить возможность, лучше задайте её слоту null.

Переменные окружения. До:

bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=openai:gpt-5.5
MODEL_FALLBACK=anthropic:claude-sonnet-4-6
TTS_MINIMAX_API_KEY=...
TAVILY_API_KEY=tvly-...

После (ключи остаются в окружении):

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  minimax-tts:
    preset: minimax-tts
    apiKey: ${TTS_MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}

slots:
  llm:
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
  tts: minimax-tts
  webSearch: tavily

Затем удалите DEFAULT_MODEL и MODEL_FALLBACK. Переменные провайдеров можно оставить как источник ссылок ${VAR}. _BASE_URL становится baseUrl провайдера (OPENAI_BASE_URL → baseUrl у openai; укажите URL или ${OPENAI_BASE_URL}, пока переменная задана), а _MODELS — его списком models. Как и преобразованный DEFAULT_MODEL, записанный здесь llm — значение сервера по умолчанию, которое рабочие пространства могут изменить; добавьте lock: [llm], чтобы закрепить его для всех. В отличие от преобразования, agent теперь следует за llm: чтобы агент Pro оставался выключенным, запишите agent: null.

server-providers.yml. До:

yaml title="server-providers.yml"
providers:
  azure:
    apiKey: ...
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
pdf:
  alidocmind:
    accessKeyId: ...
    accessKeySecret: ...
web-search:
  searxng:
    baseUrl: http://searxng:8080

После:

yaml title="openmaic.yml"
providers:
  azure:
    preset: azure
    apiKey: ${AZURE_OPENAI_API_KEY}
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
  alidocmind:
    preset: alidocmind
    credentials:
      accessKeyId: ${ALIDOCMIND_ACCESS_KEY_ID}
      accessKeySecret: ${ALIDOCMIND_ACCESS_KEY_SECRET}
  searxng:
    preset: searxng
    baseUrl: http://searxng:8080

slots:
  llm: azure:my-deployment
  document: alidocmind
  webSearch: searxng

Каждый раздел server-providers.yml превращается в провайдеров с соответствующим пресетом (providers → чат-пресеты, tts, asr, pdf → документы, image, video, web-search), а accessKeyId/accessKeySecret переходят в credentials.

MODEL_ROUTES. Сервер, в котором задан MODEL_ROUTES без openmaic.yml, не запускается:

text
MODEL_ROUTES does not carry over to the model configuration: write the per-stage models as slots in openmaic.yml (or the file named by OPENMAIC_CONFIG) and remove MODEL_ROUTES

Маршруты по этапам не соответствуют слотам один к одному, поэтому не преобразуются. Перепишите каждый маршрут как слот, к которому относится его этап:

Этап MODEL_ROUTES Слот
web-search-query-rewrite course.research
scene-outlines-stream course.outline
agent-profiles course.agents
scene-content course.content
scene-content:slide, :quiz, :interactive, :pbl course.content.slide, .quiz, .interactive, .pbl
scene-actions course.actions
chat-adapter, quiz-grade, pbl-v2-runtime и его варианты :instructor, :open-task, :evaluate, :simulator classroom
generate-classroom llm
maic-agent-driver agent
conversation-title agent.title
pbl-chat, maic-agent Нет: эти этапы удалены. Уберите их

Если несколько этапов делят один слот, назначьте ему одну модель, которую они должны использовать. thinking маршрута переходит в thinking слота, а api и contextWindow маршрута maic-agent-driver — в слот agent. До:

bash
DEFAULT_MODEL=openai:gpt-5.5
MODEL_ROUTES='{"scene-outlines-stream":"anthropic:claude-sonnet-4-6","scene-content:slide":{"model":"openai:gpt-5.4","thinking":{"effort":"low"}},"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'

После:

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}

slots:
  llm: openai:gpt-5.5
  course.outline: anthropic:claude-sonnet-4-6
  course.content.slide:
    model: openai:gpt-5.4
    thinking: { effort: low }
  agent:
    model: openai:gpt-5.5
    api: openai-completions

Как только появляется openmaic.yml, переменные провайдеров сами по себе ничего не настраивают: провайдер TTS, изображений, документов или веб-поиска, заданный только переменными (TTS_MINIMAX_API_KEY, IMAGE_SEEDREAM_API_KEY, PDF_MINERU_BASE_URL, TAVILY_API_KEY, …), больше не используется, и возможности, кроме webFetch, не получают провайдера по умолчанию. Объявите каждый используемый провайдер в providers и назначьте tts, image, document, webSearch (а также asr, video) в slots, как в примере с переменными окружения выше; неназначенный слот другой возможности остаётся ненастроенным, если рабочее пространство не назначит его в настройках моделей.

Затем удалите MODEL_ROUTES, DEFAULT_MODEL и MODEL_FALLBACK.

Docker. Смонтируйте файл рядом с приложением, а ключи оставьте в .env.local:

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

ACCESS_CODE — пароль для всего сайта

Для общих деплоев (внутренние демо, классы) можно задать пароль, чтобы посетители вводили его перед входом:

bash
ACCESS_CODE=your-secret-code

Используйте длинное случайное значение (не менее 16 символов) — этот код является единственным секретом, защищающим деплой. После успешной проверки в HTTP-only cookie сохраняется подписанный токен на 7 дней; срок действия проверяется на сервере, поэтому после истечения код нужно ввести снова. Частота проверок ограничивается только при TRUST_PROXY_HEADERS=true за доверенным обратным прокси, который перезаписывает x-forwarded-for / x-real-ip: по клиентам (10 попыток за 60 секунд), и успешная проверка сбрасывает счётчик этого клиента. Без доверенного прокси приложение не может отнести запрос к конкретному клиенту, поэтому ограничения нет — защита обеспечивается длиной и случайностью кода. Оставьте пустым для отключения.

Feature flags

Значения true или 1 включают функцию; если не указано иное, остальные значения выключают её. Переменные NEXT_PUBLIC_* внедряются в клиент при сборке, для их изменения нужна пересборка:

bash
# MAIC Editor (Pro режим)
NEXT_PUBLIC_MAIC_EDITOR_ENABLED=true

# Ссылки на материалы PPT и интерактивных сцен в режиме Pi (флаг времени сборки; режим редактирования не затрагивается)
NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED=true

# Профессиональное образование (серверный флаг)
OPENMAIC_ENABLE_VOCATIONAL=true

# Отображение UI для тестов профессионального образования
NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI=true

# Включение видеоэкспорта
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true

# Экспериментальный импорт PPTX
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true

Чат в классе всегда работает на Pi и требует модели с поддержкой вызова инструментов/функций: выберите такую модель для Класс в Настройка моделей курса или задайте слот classroom (либо слот llm, от которого он наследуется) в openmaic.yml. Для Gemini используйте нативный пресет google, а не OpenAI-совместимый адрес Google. Модель, которая во встроенном каталоге отмечена как не поддерживающая вызов инструментов, отклоняется до инференса с 400 MODEL_CONFIG_INVALID (reason: tool_calling_required), а Настройка моделей курса указывает это требование у слота класса.

Прежний чат класса на director-graph удалён, и NEXT_PUBLIC_PI_CHAT_ENABLED больше не выбирает путь чата. Удалите эту переменную из конфигурации: production-сборка принимает только отсутствие значения, пустую строку, true или 1 (точное совпадение) и при любом другом значении завершается ошибкой с инструкцией по переходу. В Docker Compose env_file сервиса (.env.local) передаёт значения времени выполнения, а не аргументы сборки.

Импорт PPTX пока экспериментален, результаты парсинга не интегрированы полностью в поток данных классов. Все NEXT_PUBLIC_* — флаги сборки, в Docker их нужно передавать как build args, нельзя просто задавать в runtime env vars.

Другие серверные опции

Дополнительные параметры сервера можно задать через env vars:

bash
# Параллельная генерация сцен; 0 или unset — последовательная
PARALLEL_SCENE_CONCURRENCY=3

# Разрешить доступ к localhost, внутренним сетям; только для self-hosted/внутренних деплоев
ALLOW_LOCAL_NETWORKS=true

# Опциональный MP4 рендер-сервис
RENDER_SERVICE_URL=http://render-service:9000

# Логирование и режимы вывода
LOG_LEVEL=info
LOG_FORMAT=pretty
LLM_THINKING_DISABLED=false

Устаревшие переменные окружения провайдеров

Эти переменные и server-providers.yml — конфигурация прежних версий. Они продолжают работать без openmaic.yml (см. Миграция со старой конфигурации), но устарели и будут удалены через две минорные версии после выхода настроек моделей. С openmaic.yml используйте их только как источник ссылок ${VAR}. DEFAULT_MODEL и MODEL_FALLBACK описаны в разделе о миграции; MODEL_ROUTES больше не читается.

LLM провайдеры

Облачные провайдеры обычно используют три переменные: API key, base URL и список моделей. Обычно требуется только API key, base URL и список моделей — опциональны. Azure OpenAI требует указания endpoint ресурса, Ollama и Lemonade API key не требуют.

bash
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=                # опциональное переопределение base URL
OPENAI_MODELS=                  # опциональный белый список моделей через запятую

Поддерживаемые префиксы провайдеров:

Префикс Провайдер
OPENAI_ OpenAI
AZURE_OPENAI_ Azure OpenAI
ANTHROPIC_ Anthropic
GOOGLE_ Google Gemini
DEEPSEEK_ DeepSeek
QWEN_ Alibaba Qwen
KIMI_ Moonshot Kimi
MINIMAX_ MiniMax (по умолчанию Anthropic-совместимый endpoint)
GLM_ Zhipu GLM
SILICONFLOW_ SiliconFlow
DOUBAO_ Doubao (ByteDance)
OPENROUTER_ OpenRouter
GROK_ xAI Grok
TENCENT_ Tencent Hunyuan
TENCENT_HUNYUAN_ Tencent Hunyuan (синоним)
XIAOMI_ Xiaomi MiMo
MIMO_ Xiaomi MiMo (синоним)
OLLAMA_ Ollama (локально)
LEMONADE_ Lemonade (локально)

Azure OpenAI использует deployment name в качестве ID модели:

bash
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=your-deployment-name

Чтобы подключить другой OpenAI-совместимый LLM-сервис, объявите в openmaic.yml провайдера openai-compatible с его baseUrl и models или добавьте его кнопкой Добавить провайдера в Настройки → Сервисы моделей → Языковые модели.

Для взаимодействия с Gemini в классе используйте нативный пресет google: добавьте Google в Настройки → Сервисы моделей и выберите его для Класс в Настройка моделей курса. Pi заранее отклоняет официальный OpenAI-совместимый адрес Google (https://generativelanguage.googleapis.com/v1beta/openai) с подсказкой по переходу; поддержка вызова инструментов через совместимый протокол отслеживается в #1556. Проверка распознаёт только этот официальный адрес и не определяет провайдера за произвольным сторонним шлюзом.

Локальные модели (Ollama и Lemonade)

Ollama и Lemonade не требуют API key; base URL локального сервиса нужно указывать в конфигурации сервера, чтобы пройти проверку SSRF:

bash
OLLAMA_BASE_URL=http://localhost:11434/v1
# LEMONADE_BASE_URL=http://localhost:13305/v1

Для ограничения доступных моделей можно использовать OLLAMA_MODELS или LEMONADE_MODELS с белым списком моделей.

TTS провайдеры

Серверные TTS провайдеры используют TTS__API_KEY и опциональный TTS__BASE_URL.

bash
# Doubao TTS (Volcengine Seed-TTS, нативный MP3)
TTS_DOUBAO_API_KEY=appId:accessKey
TTS_DOUBAO_BASE_URL=       # опциональное переопределение

# Qwen TTS
TTS_QWEN_API_KEY=
TTS_QWEN_BASE_URL=         # опциональное переопределение

# Любой OpenAI-совместимый TTS endpoint
TTS_OPENAI_API_KEY=
TTS_OPENAI_BASE_URL=

# VoxCPM2 (self-hosted TTS с клонированием голоса, см. отдельную страницу VoxCPM2)
TTS_VOXCPM_BASE_URL=http://localhost:8000

Поддерживаемые TTS префиксы: TTS_OPENAI_, TTS_AZURE_, TTS_GLM_, TTS_QWEN_, TTS_MINIMAX_, TTS_DOUBAO_, TTS_ELEVENLABS_, TTS_GOOGLE_, TTS_VOXCPM_ и TTS_LEMONADE_. Локальные Lemonade TTS и VoxCPM2 не требуют API key. Встроенный в браузер TTS не требует серверной настройки.

Администратор может принудительно отключить TTS провайдер на сервере, установив TTS__ENABLED=false. VoxCPM2 (self-hosted TTS + клонирование голоса) описан на отдельной странице VoxCPM2.

Чтобы использовать другой OpenAI-совместимый адрес TTS, объявите в openmaic.yml провайдера openai-tts с этим baseUrl и назначьте ему слот tts. Рабочие пространства не могут добавлять собственные адреса TTS в настройках моделей.

ASR (распознавание речи)

bash
# OpenAI Whisper
ASR_OPENAI_API_KEY=
ASR_OPENAI_BASE_URL=       # опциональное переопределение

# Qwen ASR
ASR_QWEN_API_KEY=
ASR_QWEN_BASE_URL=         # опциональное переопределение

# Azure ASR
ASR_AZURE_API_KEY=
ASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com

# FunASR (локально, key не нужен)
ASR_FUNASR_BASE_URL=http://localhost:8000/v1

# Lemonade ASR (локально, key не нужен)
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1

Запустите OpenAI-совместимый сервис командой funasr-server --device cuda. Он принимает WAV на /v1/audio/transcriptions и поддерживает SenseVoiceSmall, Paraformer и Fun-ASR-Nano.

Встроенный в браузер ASR не требует серверной настройки.

Чтобы использовать другой OpenAI-совместимый адрес ASR, объявите его в openmaic.yml с пресетом openai-whisper и его baseUrl и назначьте ему слот asr. Рабочие пространства не могут добавлять собственные адреса ASR в настройках моделей:

yaml title="openmaic.yml"
providers:
  my-asr:
    preset: openai-whisper
    apiKey: ${MY_ASR_API_KEY}
    baseUrl: https://asr.example.com/v1

slots:
  asr: my-asr:whisper-1

Провайдеры генерации изображений

Провайдеры изображений используют IMAGE__API_KEY и опциональный IMAGE__BASE_URL. Поддерживаемые префиксы:

IMAGE_OPENAI_, IMAGE_SEEDREAM_, IMAGE_QWEN_IMAGE_, IMAGE_NANO_BANANA_, IMAGE_MINIMAX_, IMAGE_GROK_, IMAGE_GLM_ и IMAGE_LEMONADE_.

GLM Image (Zhipu CogView) генерирует изображения моделями CogView (cogview-3-flash, cogview-4):

yaml title="openmaic.yml"
providers:
  glm-image-service:
    preset: glm-image
    apiKey: ${IMAGE_GLM_API_KEY}
    # Необязательно:
    baseUrl: https://open.bigmodel.cn/api/paas/v4

slots:
  image: glm-image-service:cogview-3-flash

Или через переменные окружения:

bash
IMAGE_GLM_API_KEY=
# Необязательно:
IMAGE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

Lemonade — локальный сервис, не требует ключа:

bash
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1

ComfyUI Image не требует API-ключа. Он работает в вашей собственной сети, поэтому настраивается развёртыванием: объявите его в openmaic.yml с адресом сервера ComfyUI и назначьте ему слот image.

yaml title="openmaic.yml"
providers:
  comfyui:
    preset: comfyui-image
    baseUrl: http://host.docker.internal:8188

slots:
  image: comfyui

Поместите JSON рабочего процесса, экспортированный в формате API, в каталог public/ OpenMAIC. Имена файлов должны соответствовать comfyui-*.json или содержать workflow; OpenMAIC автоматически находит эти файлы и предлагает их как рабочие процессы. В Docker добавьте рабочий процесс до сборки образа или смонтируйте отдельный файл в /app/public/ контейнера. Поскольку провайдер объявлен развёртыванием, серверу ComfyUI на хосте или в частной сети не нужен ALLOW_LOCAL_NETWORKS.

Провайдеры генерации видео

Видео провайдеры используют VIDEO__API_KEY и опциональный VIDEO__BASE_URL. Поддерживаемые префиксы:

VIDEO_SEEDANCE_, VIDEO_KLING_, VIDEO_SORA_, VIDEO_MINIMAX_, VIDEO_GROK_ и VIDEO_HAPPYHORSE_.

Разбор документов и медиа

Формат материалов зависит от выбранного парсера. Поддерживаются текст, PDF, Office-документы, изображения и часть аудио/видео форматов; возможности зависят от провайдера.

bash
# MinerU self-hosted
PDF_MINERU_BASE_URL=http://localhost:8888

# MinerU self-hosted опциональный backend
PDF_MINERU_BACKEND=pipeline

# MinerU Cloud
PDF_MINERU_CLOUD_API_KEY=
PDF_MINERU_CLOUD_BASE_URL=https://mineru.net/api/v4

# AliDocMind (использует AccessKey Alibaba Cloud, а не отдельный API key)
ALIDOCMIND_ACCESS_KEY_ID=
ALIDOCMIND_ACCESS_KEY_SECRET=
ALIDOCMIND_BASE_URL=       # опциональное переопределение

unpdf встроен в OpenMAIC и подходит для базового разбора PDF. Для Office-документов, изображений и материалов с OCR, таблицами, формулами или сложной версткой рекомендуется MinerU или AliDocMind.

AliDocMind поддерживает PDF, DOCX, PPTX, XLSX, а также PNG, JPG/JPEG, BMP, GIF. Текущая поддержка аудио/видео через AliDocMind: видео форматы MP4, MOV, AVI, MKV, WMV; аудио MP3, WAV, AAC. M4A не поддерживается.

Поисковые провайдеры

Настройте Tavily, Exa, Firecrawl, Bocha, Brave, Baidu, SearXNG или MiniMax:

bash
TAVILY_API_KEY=
TAVILY_BASE_URL=           # опциональное переопределение

EXA_API_KEY=
EXA_BASE_URL=https://api.exa.ai  # опциональное переопределение

FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev  # опциональное переопределение

BOCHA_API_KEY=
BOCHA_BASE_URL=            # опциональное переопределение

BAIDU_API_KEY=
BAIDU_BASE_URL=https://qianfan.baidubce.com  # опциональное переопределение

# Self-hosted SearXNG, API key не нужен
SEARXNG_BASE_URL=

WEB_SEARCH_MINIMAX_API_KEY=
WEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com  # опциональное переопределение

Brave и SearXNG не требуют API key. У веб-поиска нет переключателя для отдельной генерации: он выполняется, когда слот webSearch рабочего пространства разрешается, а его выключение (webSearch: null в openmaic.yml или переключателем поиска в Настройки → Настройка моделей курса) влияет на последующие генерации этого рабочего пространства. Grok использует поиск через инструменты Grok LLM, это не отдельный поисковый провайдер.

server-providers.yml

Устарело, используйте openmaic.yml. Если есть оба файла, модели настраивает только openmaic.yml.

Кроме env vars, можно положить конфигурацию зарегистрированных провайдеров в server-providers.yml в корне проекта. Файл загружается при старте сервера, структура соответствует категориям env vars:

yaml title="server-providers.yml"
providers:
  openai:
    apiKey: sk-...
    baseUrl: https://api.openai.com/v1
    models:
      - gpt-5.5
  anthropic:
    apiKey: sk-ant-...

tts:
  doubao-tts:
    apiKey: appId:accessKey

asr:
  openai-whisper:
    apiKey: sk-...

pdf:
  mineru:
    baseUrl: http://localhost:8888
  alidocmind:
    accessKeyId: your-access-key-id
    accessKeySecret: your-access-key-secret

image:
  seedream:
    apiKey: ...

video:
  seedance:
    apiKey: ...

web-search:
  tavily:
    apiKey: tvly-...
  exa:
    apiKey: ...
  firecrawl:
    apiKey: fc-...
  searxng:
    baseUrl: http://localhost:8080

Переменные окружения по отдельным полям переопределяют соответствующие настройки в YAML. Ключи должны совпадать с зарегистрированными в коде ID провайдеров, например openai, doubao-tts, openai-whisper, mineru, alidocmind, seedream, seedance, exa, tavily или searxng. Неизвестные ID нельзя использовать для регистрации кастомных LLM провайдеров.

Авторизация моделей для провайдеров с серверными ключами

Для провайдеров, которые отправляют выбранную модель вверх по цепочке с учётными данными развертывания, задайте явный список models в openmaic.yml (providers..models) или соответствующую переменную окружения *_MODELS / список models в разделе server-providers.yml. Это относится к чату, изображениям, видео, синтезу речи, распознаванию речи и веб-поиску на основе моделей. Идентификаторы моделей сравниваются точно, с учётом регистра, после удаления окружающих пробелов при загрузке конфигурации. Детерминированные псевдонимы адаптеров авторизуются по объявленному идентификатору модели. Запросы к моделям вне списка возвращают HTTP 403 с PROVIDER_DISABLED и сообщением «This model is not authorized for server-managed credentials».

Несовместимое изменение: без явного списка доступны только модели развертывания по умолчанию и резервные модели, а также основные и резервные модели, на которые ссылаются слоты этой возможности. В устаревшей конфигурации DEFAULT_MODEL и MODEL_FALLBACK разрешают модели чата. Медиаслот, указывающий только провайдера, разрешает одну модель адаптера по умолчанию, а не весь каталог пресета. Назначения в рабочем пространстве не расширяют набор, разрешённый развертыванием. Регистрация и синтез голосов также должны разрешать модель, выбранную для совместимости голоса.

Для миграции перечислите все модели, которые пользователи должны иметь возможность выбрать, перезапустите сервер и обновите выборы в рабочих пространствах, которые больше не разрешены. Для каждого провайдера с учётными данными, но без списка, журнал запуска называет параметр, который нужно добавить. Собственные учётные данные пользователей и локальные провайдеры без ключа, такие как Ollama и Lemonade, не затрагиваются. Провайдер без учётных данных не ограничивается, поэтому не направляйте его на шлюз, который сам добавляет учётные данные вышестоящего сервиса (например, внутренний OpenAI-совместимый прокси): выдайте шлюзу ключ и настройте его как провайдер с учётными данными и списком models или ограничьте модели в самом шлюзе. Парсерам документов и сервисам поиска без выбора модели (например, Tavily) список моделей не нужен.

yaml
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
    models: [gpt-5.4-mini]
slots:
  llm: openai:gpt-5.4-mini

Эквивалент в устаревшей конфигурации: OPENAI_API_KEY=... и OPENAI_MODELS=gpt-5.4-mini. Для медиа используйте префикс возможности, например IMAGE_SEEDREAM_MODELS, VIDEO_SEEDANCE_MODELS, TTS_OPENAI_MODELS, ASR_OPENAI_MODELS и WEB_SEARCH_CLAUDE_MODELS.

Веб-поиск и загрузка DeepSeek

Пресет deepseek использует общие DEEPSEEK_API_KEY и базовый URL API для чата, веб-поиска и размещённого извлечения Markdown. Поиск вызывает POST /v1/tools/search, возвращает до 50 результатов и обрезает запрос до 128 кодовых точек Unicode. Текст берётся из summary, а при его отсутствии — из snippet. Загрузка вызывает POST /v1/tools/fetch и читает content и необязательный непустой title.

В старой конфигурации включите WEB_SEARCH_DEEPSEEK_ENABLED=true или явно добавьте web-search.deepseek в server-providers.yml. Одни учётные данные чата не включают поиск и не заменяют текущий поисковый сервис по умолчанию. WEB_SEARCH_DEEPSEEK_ENABLED=false принудительно отключает поиск. При наличии openmaic.yml старая переменная не назначает слот: явно объявите провайдера и назначьте webSearch и/или webFetch.

The default API base is https://api.deepseek.com/v1. On api.deepseek.com, every base path (including /beta) is normalized to /v1 before appending /tools/search or /tools/fetch. Custom gateway bases preserve their configured path prefix; a bare root gets /v1 first. Custom endpoints belong in deployment configuration, while client and workspace overrides remain restricted to official endpoints. The default fetch reader remains builtin. See DeepSeek official pricing for current request charges. Failed requests and searches with no results are not billed.

yaml
providers:
  deepseek:
    preset: deepseek
    apiKey: ${DEEPSEEK_API_KEY}
    baseUrl: https://api.deepseek.com/v1
slots:
  webSearch: deepseek
  webFetch: deepseek

Чат и веб-поиск Kimi

Объявите одну учётную запись Kimi и назначьте ей оба слота:

yaml
providers:
  kimi:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    # Необязательно: https://api.moonshot.ai/v1 для международной Open Platform.
    # baseUrl: https://api.moonshot.ai/v1
slots:
  llm: kimi:kimi-k3
  webSearch: kimi

Для ключа Coding Plan укажите preset: kimi-coding-plan и llm: kimi:kimi-for-coding; базовый URL по умолчанию — https://api.kimi.com/coding/v1. Ключи Open Platform и Coding Plan должны использовать соответствующие им базовые URL. В устаревшей конфигурации KIMI_API_KEY и необязательная KIMI_BASE_URL предоставляют учётные данные для поиска только после явного включения: задайте WEB_SEARCH_KIMI_ENABLED=true или добавьте web-search.kimi в server-providers.yml (например, с enabled: true). Одни лишь учётные данные чата не включают поиск и не меняют поискового провайдера по умолчанию. WEB_SEARCH_KIMI_ENABLED=false отключает поиск Kimi даже при назначенном слоте. WEB_SEARCH_KIMI_ENABLED — единственный переключатель поиска Kimi. KIMI_MODELS применяется только к чату. При конфликте устаревших учётных записей чата и поиска выводится предупреждение, а запись поиска пропускается; для разных учётных записей объявите отдельные ID провайдеров в openmaic.yml. Если существует openmaic.yml, явно объявите провайдера и назначьте слоты. Настройки и мастер первого запуска позволяют использовать обе возможности через одну учётную запись.

Конечная точка выбирается по базовому пути: после удаления завершающих косых черт путь, оканчивающийся на /coding/v1, использует /search, а остальные — /tools/search_pro. Пользовательские прокси, настроенные в развертывании, должны сохранять эту структуру путей. Мастер рекомендует поиск только для официальных базовых URL учётных записей, поэтому пользовательский прокси чата рабочего пространства не получает назначение поиска автоматически.

Если существует openmaic.yml, объявите providers.: { preset: kimi } и назначьте slots.webSearch: ; устаревшая переменная WEB_SEARCH_KIMI_ENABLED не может назначить этот слот.

Слот провайдера загрузки веб-страниц

webFetch выбирает сервис чтения для инструмента агента fetch_url. В старой, пустой и YAML-конфигурации по умолчанию используется builtin, в том числе когда корень не указан при lock: all. Явное webFetch: null отключает загрузку и убирает инструмент и его инструкции из последующих запусков. Каждый запрос использует одного провайдера без резервного или дополнительного сервиса. Недействительная ссылка на провайдера или адрес рабочего пространства, запрещённый политикой, приводит к сбою всего запуска агента при разрешении возможностей, ещё до старта агента, как и для webSearch. Это ошибка конфигурации, а не результат неудачного вызова fetch_url; переключения на встроенный сервис не происходит.

Провайдер Учётные данные Извлечение
builtin Не нужны Локальный Readability/Turndown; PDF используют слот document
kimi Ключ и базовый URL существующего провайдера Kimi Размещённый сервис извлечения Markdown, включая URL файлов PDF
deepseek Ключ чата DeepSeek и базовый URL API Размещённое извлечение Markdown
firecrawl Ключ API Firecrawl (preset: firecrawl) Размещённый сервис извлечения основного содержимого страницы в Markdown, включая URL файлов PDF
yaml
providers:
  reader:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    baseUrl: https://api.moonshot.cn/v1
slots:
  webFetch: reader

Оба пресета, kimi и kimi-coding-plan, предоставляют webFetch. Для учётных данных Coding Plan используйте preset: kimi-coding-plan; его базовый URL по умолчанию — https://api.kimi.com/coding/v1. Явно заданный базовый URL сохраняется. Базовые URL Moonshot Open Platform (https://api.moonshot.cn/v1 и https://api.moonshot.ai/v1) используют /tools/fetch, Coding Plan — /fetch. Оба отправляют POST с { "url": ... } и Bearer-аутентификацией. Адаптер принимает markdown или content. Мейнтейнер проверил POST https://api.kimi.com/coding/v1/fetch с действующим ключом и подтвердил ответ { markdown, title, url }; доступность международных адресов пока не проверена. Запросы учитывают настройку proxy провайдера и стандартные переменные окружения прокси.

DeepSeek также предоставляет размещённое извлечение Markdown через пресет deepseek с ключом и базовым URL чата. Явно назначьте webFetch, как в примере выше. Используется /v1/tools/fetch, адрес шлюза из развёртывания сохраняется. Тайм-ауты и одна повторная попытка только при транспортной ошибке совпадают с Kimi; HTTP-ошибки и неверные ответы не повторяются.

Используйте существующие учётные данные в Настройки → Сервисы моделей → Загрузка веб-страниц и выберите сервис в Настройка моделей курса → Загрузка веб-страниц. Ключ также используется чатом с этим провайдером: его изменение или удаление влияет на чат. Закреплённые администратором карточки доступны только для чтения. Первичная настройка сохраняет встроенный сервис, пока другой не выбран явно. Учётные данные рабочего пространства могут использовать официальные базовые URL API; собственные адреса загрузки и прокси администратор должен объявить в openmaic.yml.

В отличие от kimi, у firecrawl собственный ключ: объявите провайдера с preset: firecrawl и apiKey (например, ${FIRECRAWL_API_KEY}) или введите ключ в Настройки → Сервисы моделей → Загрузка веб-страниц. Он отправляет POST /v2/scrape с аутентификацией Bearer и получает основное содержимое страницы в Markdown. Базовый URL по умолчанию — https://api.firecrawl.dev; базовый URL самостоятельно размещённого Firecrawl должен объявить администратор в openmaic.yml.

Тот же провайдер firecrawl обслуживает и веб-поиск (POST /v2/search): один ключ и один базовый URL подходят для обоих, поэтому одного провайдера можно назначить и в slots.webSearch, и в slots.webFetch. Устаревшие переменные FIRECRAWL_API_KEY и FIRECRAWL_BASE_URL настраивают именно этого провайдера; загрузка веб-страниц остаётся на builtin, пока webFetch не назначен ему.

До передачи URL размещённому сервису выполняются проверка разрешённых URL сеанса, строгая нормализация URL и проверка всех адресов локального DNS. Те же проверки применяются к возвращённому конечному URL до принятия содержимого. Размещённый сервис управляет собственным DNS и промежуточными перенаправлениями, поэтому локальные проверки не могут закрепить его сетевые подключения. Текст остаётся недоверенными данными: применяются обнаружение страниц проверки доступа, ограничения размера в байтах, отметка об усечении, прежнее сохранение веб-материалов и формат результата {trusted, untrusted}.


Packages Docs Content Docs Configuration.Zh Cn (`packages/docs/content/docs/configuration.zh-cn.mdx`)


title: 配置说明

OpenMAIC 有三个配置来源,各自只负责一件事:

来源 由谁编写 内容 何时生效
openmaic.yml 运维人员 服务商、服务端默认分配、锁定的槽位,以及用户能否添加自己的 Key 启动时(修改后需重启)
Web 端的模型设置 工作区的用户 用户自己的服务商,以及 openmaic.yml 未锁定槽位的分配,保存在数据库中 立即生效
环境变量(.env.local、容器 Secret) 运维人员 openmaic.yml 引用的密钥,以及其他所有服务端选项 启动时

DATABASE_URL is required; other environment variables are optional (see Deployment). Providers that use server credentials for model selection also need authorized model lists, as described below. See .env.example for environment variables, openmaic.example.yml for a sample model configuration, and Supported models for preset and model IDs.

早期版本只能通过服务商环境变量、server-providers.yml、DEFAULT_MODEL 和 MODEL_ROUTES 配置模型。除 MODEL_ROUTES 外,这些方式目前仍然可用,但已弃用,见从旧配置迁移。

模型与服务商

基本概念

  • 服务商(provider):服务端可以调用的一个账号,例如一个 OpenAI key、一个 MiniMax Token Plan 或一个自托管的 OpenAI 兼容服务。每个服务商有一个你自己起的 ID,以及一个预设(preset),说明它是哪家厂商、覆盖哪些能力。
  • 槽位(slot):产品里用到 AI 的一处地方,例如课程大纲、幻灯片内容、语音合成或联网搜索。每种能力的槽位构成一棵树;没有分配的槽位沿用父槽位。
  • 分配(assignment):槽位使用哪个服务商的哪个模型(provider:model),可以附带调用参数和回退模型;也可以写 null 关闭该槽位。

一项能力是否可用,只取决于它的槽位能否解析到一个分配,没有单独的开关。

openmaic.yml

服务端从启动目录(仓库根目录;Docker 镜像中为 /app)读取 openmaic.yml。设置 OPENMAIC_CONFIG 可以改用其他路径;相对路径同样相对于启动目录解析,且此时文件必须存在。没有该文件时,服务端使用 Web 端的设置,以及仍在设置中的旧版变量。

该文件只在启动时读取一次,修改后需要重启服务。服务启动前会完整校验文件:未设置的 ${VAR}、未知的预设或槽位、引用了未声明的服务商,或服务商不提供槽位所需的能力,都会让服务拒绝启动,并逐条列出问题所在的位置:

text
[boot] Invalid server configuration; the server will not start: Invalid model configuration in /app/openmaic.yml:
  - providers.openai.apiKey: environment variable OPENAI_API_KEY is not set
  - slots.course.outlines: unknown slot

不要把密钥直接写进文件:用 ${VAR} 引用,并在环境变量中设置。来自环境变量的值不会出现在这些错误信息里。

完整示例

yaml title="openmaic.yml"
providers:
  # <服务商 ID>:小写字母、数字和连字符
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}      # 启动时从环境变量读取
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  openai-image:                    # 同一个 key,用于图像生成
    preset: openai-image
    apiKey: ${OPENAI_API_KEY}
  minimax:                         # Token Plan:对话、图像、视频、TTS、搜索
    preset: minimax
    apiKey: ${MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}
  local:                           # 任意 OpenAI 兼容服务
    preset: openai-compatible
    baseUrl: http://ollama:11434/v1
    models: [qwen3:8b]

slots:
  llm: openai:gpt-5.5              # 下面所有对话槽位的默认值
  course.outline:
    model: anthropic:claude-sonnet-4-6
    fallback: openai:gpt-5.4-mini  # 遇到可重试的失败时换用一次
  course.content.slide:
    model: minimax:MiniMax-M3
    thinking: { enabled: false }
    fallback: minimax:MiniMax-M2.7
  classroom: local:qwen3:8b        # 模型 ID 里可以有冒号
  agent:
    model: openai:gpt-5.5          # 需要支持工具调用
    api: openai-completions
  tts: minimax:speech-2.8-turbo
  image: openai-image:gpt-image-2
  webSearch: tavily                # 只写服务商:使用它的默认模型
  video: null                      # 默认关闭;用户可以自行开启

lock: [tts]                        # 对所有人固定,连同其子树
allowUserKeys: false               # 用户只能在这些服务商中选择,不能自行添加

使用这份文件时,slots 下写出的槽位是服务端默认值:用户可以在模型设置中逐一修改,只有被 lock 固定的 tts 对所有人不可更改。文件没写的对话槽位(例如 course.agents)沿用父槽位(llm);没写的能力根槽位(例如 asr 或 document)在用户分配之前保持未配置。设置 allowUserKeys: false 后,用户只能在这里声明的服务商中选择。

服务商

providers 下的每一项声明一个服务商。键名就是它的 ID,供槽位引用:1 到 63 个小写字母、数字和连字符,以字母或数字开头。

字段 是否必填 含义
preset 是 使用哪个内置预设,见支持的模型 → 预设。
apiKey 通常需要 API key。不需要 key 的服务(本地服务、Brave、SearXNG、unpdf)可以省略。
baseUrl 部分预设需要 替代预设自带端点的地址。openai-compatible、azure、searxng 和 mineru 必填。
models For server-managed credentials Authorized model ids. Deployment slot defaults/fallbacks for the capability are also authorized; catalogues and workspace assignments do not grant additional models.
proxy 否 该服务商请求使用的 HTTP 代理地址。
credentials 部分预设需要 不使用单个 key 的服务所需的密钥对。AliDocMind 使用 accessKeyId 和 accessKeySecret。
options 否 非机密的、服务商特有的设置,例如 voxcpm-tts 的 backend。名称由字母、数字、_ 和 - 组成;值为字符串、数字或布尔值(不能嵌套映射或列表),可以使用 ${VAR}。模型设置会显示 options,因此绝不要在其中放 key。仅限部署:模型设置无法设置它们。

大多数预设只覆盖一种能力,与内置 registry 一一对应:openai 预设只有对话,OpenAI 的图像、语音合成和语音识别分别是 openai-image、openai-tts 和 openai-whisper 预设。每种能力声明一个服务商;同一个 key 覆盖多种能力时,引用同一个 ${VAR} 即可。Token Plan 预设(minimax、tokendance、volcengine-ark、kimi-coding-plan)用一个 key 覆盖多种能力,每种能力使用该套餐自己的端点。服务商上的 baseUrl 会替换它覆盖的所有能力的端点,因此只有当 Token Plan 的全部能力都一起迁移时,才给它设置 baseUrl。

Amazon Bedrock(preset: bedrock)使用服务端的 AWS 凭证链(环境变量、profile 或 role)签名请求,区域取自 BEDROCK_REGION、AWS_REGION 或 AWS_DEFAULT_REGION;apiKey 是可选的 bearer token。

槽位

槽位 能力 父槽位 作用
llm chat — 默认对话模型:下面任何槽位都不覆盖的对话调用
course.research chat llm 课程调研时改写联网搜索查询
course.outline chat llm 场景大纲
course.agents chat llm 老师和同学智能体的人设
course.content chat llm 场景内容(没有专属槽位的场景类型)
course.content.slide chat course.content 幻灯片场景
course.content.quiz chat course.content 测验场景
course.content.interactive chat course.content 互动场景
course.content.pbl chat course.content 项目式学习(PBL)场景
course.actions chat llm 场景动作(智能体在每个场景里说什么、做什么)
classroom chat llm 课堂对话、测验评分和 PBL 运行时
agent chat llm Pro 工作台智能体。需要工具调用能力。 还支持 api 和 contextWindow
agent.title chat agent Pro 会话标题。只能在 openmaic.yml 中配置,模型设置里不显示
tts tts — 语音合成
asr asr — 语音识别(语音输入、音视频转写)
image image — 图像生成
video video — 视频生成
webFetch webFetch — 网页内容提取(默认:builtin)
webSearch webSearch — 联网搜索
document document — 文档解析服务(MinerU、MinerU Cloud、AliDocMind)。内置的 PDF 文本提取不需要服务

槽位的解析方式:

  • 从调用所在的槽位开始,逐级向上直到能力根槽位,遇到的第一个分配生效。因此父槽位的分配就是整棵子树的默认值;修改父槽位,会同时改变所有没有自己分配的子槽位。
  • 在锁定的子树内(该槽位或其祖先被 lock 列出,或使用 lock: all),只看 openmaic.yml:向上查找到被锁定的槽位为止,文件中写出的第一个分配生效。其他位置以用户的选择为准:先从该槽位向上直到根槽位查找工作区自己的分配,只有完全没有时,才同样从该槽位向上查找 openmaic.yml 写的默认值。因此,工作区设置了父槽位时,没有被工作区改过的子槽位会跟随它,即使服务端在子槽位上写了默认值;服务端的值只是默认值。
  • null 会停止向上查找:该槽位,以及它下面所有没有自己分配的槽位,都会被关闭。
  • 一直到根槽位都没有分配时,该能力处于未配置状态。调用会报错并指出槽位名,不会回退到任何厂商。
  • 要求(requirement)属于声明它的槽位,按该槽位最终解析到的模型检查,无论是自己分配的还是继承的。agent 要求支持工具调用;如果内置模型目录表明它的模型不支持,智能体会拒绝运行,而不是勉强凑合。目录里没有的模型允许使用。agent.title 没有要求,因此可以用更小的模型。

分配

分配有四种写法:

yaml
slots:
  llm: openai:gpt-5.5            # 服务商:模型
  webSearch: tavily              # 只写服务商:使用它的默认模型
  course.outline:                # 带参数的对象
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
    thinking: { effort: low }
  video: null                    # 关闭

对象写法支持以下字段:

字段 含义
model 必填。服务商:模型;对话以外的槽位也可以只写 服务商。
fallback 换用一次的回退模型,写法同上。仅适用于语言模型槽位,见回退模型。
thinking 对话模型的推理参数:enabled(true/false)、mode(default、disabled、enabled、auto)、effort(none、minimal、low、medium、high、xhigh、max)、level(minimal、low、medium、high)、budgetTokens、excludeReasoningOutput。各服务商适配器只应用其 API 支持的部分。
api 仅 agent:传输协议,openai-completions(默认)或 openai-responses。
contextWindow 仅 agent:压缩上下文时假定的窗口大小,用于比模型目录中的值更小的情况。

agent 槽位不能设置 thinking.effort:它的工具调用传输无法同时使用推理强度和函数工具。

只写服务商 ID 的引用

不带 :model 的引用表示该服务商的默认模型:Token Plan 为该能力推荐的模型,否则是内置 registry 的默认模型。搜索和文档服务大多没有可选的模型,所以通常写成 webSearch: tavily 或 document: mineru-cloud。对话槽位必须写明模型,llm: openai 会在启动时被拒绝。

回退模型

回退模型只适用于语言模型槽位,即 llm 及其下的所有槽位。在 tts、asr、image、video、webSearch、webFetch 或 document 槽位上写 fallback 会被拒绝:openmaic.yml 在启动时拒绝,模型设置在保存时拒绝。

当主模型的调用在自身重试之后仍然以可重试的方式失败时,会换用槽位的 fallback 再试一次。可重试的失败包括:SDK 判定的临时错误、超时、网络错误、限流(429)、容量不足(503)或输出为空。服务商明确返回额度用尽时(OpenAI 兼容格式的额度错误码,例如 insufficient_quota,不论 HTTP 状态码),不会在主模型上重试,调用会直接换用回退模型。内容安全拒绝和其他 4xx 错误不会触发回退。回退对普通调用和流式调用都生效,并沿用槽位的 thinking 参数。

回退模型属于分配,随分配一起被继承:没有自己分配的 course.content.slide 会使用 course.content 的模型及其回退模型。有要求的槽位不会换用不满足要求的回退模型。

关闭某项能力

在它的槽位上写 null。video: null 默认关闭视频生成,用户仍可在模型设置中开启;再加上 lock: [video] 即对所有人保持关闭。course.content.pbl: null 只关闭 PBL 场景生成,course.content 的其余部分照常工作。对已关闭槽位的调用会立即失败,媒体相关接口返回 403,而不是换用其他厂商。

默认值、锁定与模型设置

openmaic.yml 在 slots 下写出的内容是服务端的默认值:用户可以在模型设置中修改,卡片上会标注服务端默认,恢复服务端默认会撤销用户的修改。在 lock 下列出的槽位由管理员固定,对所有人生效,并连同整棵子树一起固定:

yaml
lock: [llm, video]   # 这些槽位及其下所有槽位
# lock: all          # 所有槽位
  • lock 列出的每个槽位都必须在 slots 中写出(null 也算:video: null 加 lock: [video] 会对所有人关闭视频)。未知槽位,或 slots 中没有写出的锁定槽位,会让服务在启动时拒绝运行,并指出其位置(lock.1: ...)。
  • 锁定的槽位同时固定其子槽位:使用 lock: [llm] 时,任何用户都不能设置 course.outline 或其他语言模型槽位,它们全部只按 openmaic.yml 解析。
  • lock: all 固定所有槽位,无论是否写出;slots 没写的能力根槽位因此对所有人都是未配置。但未指定的 webFetch 仍使用 builtin;设置 webFetch: null 可将其关闭。
  • null、服务端默认值与锁定可以组合:只写 video: null 时默认关闭,用户可以开启;加上 lock: [video] 后保持关闭。

设置界面会根据配置呈现三种形态之一:

形态 条件 显示内容
自己配置 开启 allowUserKeys(默认) Token Plan、模型服务和课程模型配置
选择模型 allowUserKeys: false 只显示课程模型配置,在 openmaic.yml 声明的服务商中选择
由管理员配置 所有槽位都被锁定(lock: all) 课程模型图,每张卡片均为只读(显示其模型,或已关闭、未设置)

所有控件遵循同一条规则:只有使用它能改变什么时才显示。模型服务中某个能力的标签页及其添加服务入口,只在该能力还有用户可设置的槽位时出现;Token Plan 只在某个套餐还能填入未锁定的槽位时出现,接入套餐也绝不会改动锁定的槽位。课程模型配置中被锁定的卡片显示为一行只读内容,并标注由管理员固定。首页的模型选择器只在默认模型可以修改时出现(llm 未被锁定,且有服务商提供语言模型)。

Web 端的模型设置读写当前工作区的同一棵树:

  • Token Plan:接入套餐会用该 key 添加套餐的服务商,并为套餐推荐、且仍未分配的槽位填入模型。解除接入会删除该服务商,原本使用它的槽位重新跟随父槽位。
  • 模型服务:每种能力一个标签页(语言模型、图像、视频、语音合成、语音识别、文档解析、联网搜索),列出各自的服务。来自 openmaic.yml 的服务商以只读方式显示,不显示任何关于 key 的信息。工作区配置的服务会成为工作区服务商:key 只能写入,只显示类似 …abcd 的掩码;自定义地址仅限语言模型(Azure 语音服务使用其官方区域地址,例如 https://<region>.tts.speech.microsoft.com);自托管服务与密钥对需在 openmaic.yml 中配置。测试按钮在服务端检查已保存的服务商。
  • 课程模型配置:主线模型就是 llm 槽位,管线的每个环节对应自己的槽位(跟随主线即该环节没有自己的分配);图像、视频、语音、搜索和转写的开关会把对应槽位设为 null。被锁定的槽位显示为一行只读内容;使用服务端默认值的槽位在用户修改后提供恢复服务端默认。

早期版本保存在浏览器中的服务商设置(服务商及其 key 和 Base URL、所选模型、Token Plan 接入状态,以及每种能力的选择)会一次性导入工作区。导入不会替换工作区已有的设置或 openmaic.yml 锁定子树中的槽位,并会跳过被 allowUserKeys: false 或校验拒绝的条目。收到 2xx 响应后,只有工作区已持有的内容会从浏览器中删除:服务端跳过的每个条目(媒体服务的自定义地址、工作区不可使用的预设、与部署声明重名的服务商 id 等)都会连同 key 保留在此浏览器中,不会再次发送,提示一次,并在「设置 → 模型服务」中连同原因列出,直到重新设置或被丢弃。带官方区域地址的 Azure 语音服务商会被导入。收到 400 时整份导入内容被拒绝:不会再次发送,其中各条目会连同 key 保留在此浏览器中,并与被跳过的条目一起列出;导入内容无法读取时会被丢弃。只有在 401、404、409、5xx 或网络错误时,导入内容才会保留在浏览器中,供之后重试。明确关闭过的语音输入会变为 asr: null;在已有可用服务商时关闭的语音讲解、图像或视频会变为 tts: null、image: null 或 video: null(openmaic.yml 锁定的槽位保持不变)。以下内容需要手动重新设置:按阶段设置的模型(在课程模型配置中,或在 openmaic.yml 的 slots 中)、推理设置(槽位上的 thinking)、自定义语音合成与语音识别服务商以及 AliDocMind 的密钥对(在 openmaic.yml 中;它们会保留在浏览器中,与被跳过的条目一起列出)、VoxCPM 后端(options.backend),以及百度搜索子来源(使用服务端默认值)。关闭的调研开关不会迁移(它只停止课程调研,对话和智能体仍会搜索),仅因默认值而处于关闭的开关也不会迁移:每项能力只要其槽位能解析就会运行。

工作区即持久化文档中所说的所有者(owner):默认的 Docker Compose 部署(单用户模式)只有一个工作区;多用户部署中,每个已登录账号各有一个工作区。

allowUserKeys

yaml
allowUserKeys: false

allowUserKeys(默认 true)决定用户能否添加自己的服务商、Key 和 Token Plan。设为 false 时,用户只能在 openmaic.yml 声明的服务商中,为未锁定的槽位选择模型:不显示 Token Plan 和添加服务入口,工作区之前添加的服务商也不再使用。请求自带的模型、密钥或端点(已弃用的 x-model、x-api-key、x-base-url 请求头以及媒体接口的服务商字段)也会被忽略,服务商测试按钮的请求头形式会被拒绝。由部署方承担全部费用的多用户部署通常会设置它,并锁定需要固定的槽位。

密钥与 OPENMAIC_SECRET_KEY

  • openmaic.yml 中的 key 通过 ${VAR} 来自环境变量,不会写入数据库,也不会发送到浏览器。
  • 工作区在模型设置中填写的 key 保存在数据库中,使用实例密钥 OPENMAIC_SECRET_KEY 以 AES-256-GCM 加密。
  • 未设置 OPENMAIC_SECRET_KEY 时,服务端会在首次使用时于 data/instance-secret.key(Docker Compose 中位于 openmaic-data 数据卷内)生成一个随机密钥,之后一直使用它。

请把该密钥和数据库备份放在一起:密钥丢失或更换后,之前保存的 key 无法读取,需要重新填写(模型设置会标出这些 key)。多个实例共用同一数据库时,或服务没有持久化数据目录时(例如未挂载数据卷的容器),请显式设置 OPENMAIC_SECRET_KEY;任意字符串都可以,例如 openssl rand -base64 32 的输出。

不设置它时,如果数据目录不能跨重启保留(没有挂载卷的容器、临时磁盘),每次启动都会生成新密钥;多个副本也会各自生成一个;工作目录只读时则无法创建密钥,在模型设置中保存 key 会失败。启动时,若 OPENMAIC_SECRET_KEY 未设置且数据目录不可写,或数据库中已有 key 是用与当前不同的密钥加密的(例如 data/instance-secret.key 丢失),服务端会记录警告,但仍会正常启动。

bash
OPENMAIC_SECRET_KEY=...        # 模型设置中保存的 key 所用的实例密钥
OPENMAIC_CONFIG=/etc/openmaic/openmaic.yml   # 可选:openmaic.yml 的其他路径

工作区可以添加什么

openmaic.yml 中的服务商可以是 schema 允许的任何内容。工作区在模型设置中添加的服务商限制更多,因为其端点和 key 由 Web 端的用户填写:

  • 不提供 Amazon Bedrock:它会使用服务端自己的 AWS 凭证签名。
  • 不能设置 proxy,也不能使用密钥对(credentials):AliDocMind 等使用密钥对认证的预设只能由部署配置。
  • 只有提供对话能力的预设才能设置自定义 baseUrl,且带自定义端点的服务商只能用于对话槽位。URL 不能包含凭证、查询字符串或片段,并且必须解析到公网地址,除非服务端设置了 ALLOW_LOCAL_NETWORKS=true。对它的请求不会跟随重定向。
  • 本地模型服务(Ollama、Lemonade)必须填写自己的端点,不能使用预设默认的 localhost。
  • 自托管的媒体、搜索和文档服务(VoxCPM2、FunASR、Lemonade 语音与图像、ComfyUI、SearXNG、自托管 MinerU)运行在服务端自己的网络中,只能由部署配置。
  • 运维人员用旧版 <CAP>_<VENDOR>_ENABLED=false 变量关闭的服务商不会提供。
  • 工作区不能声明 openmaic.yml 已经声明过的服务商 ID。

如果希望用户访问内网服务,请在 openmaic.yml 中声明这些服务商。

API 调用方与已弃用的请求字段

生成请求不再需要携带 key 或模型选择:/api/generate-classroom 等无浏览器 API 调用会按调用方所在的工作区,通过与 Web 端相同的槽位解析模型。

旧的请求头 x-model、x-api-key、x-base-url、x-provider-type、x-model-routes、x-image-provider、x-image-model、x-video-provider、x-video-model,以及语音、搜索和文档接口中对应的请求体字段,均已弃用。只有当它们会影响的槽位没有分配,或者在仍通过旧变量配置的服务端上只有由旧变量转换得到的默认值时(DEFAULT_MODEL 一直排在请求指定的模型之后),才会生效;openmaic.yml 中的默认值、工作区自己的选择和锁定的槽位优先,设为 null 的槽位无论请求写了什么都会拒绝。设置 allowUserKeys: false 时,它们一律被忽略。请求第一次使用这些字段时,服务端会记录一条警告。它们将在上述模型设置发布两个次版本后移除。

从旧配置迁移

没有 openmaic.yml 时,服务端会在启动时转换旧配置,记录一条弃用提示,并为每个无法转换的条目各记录一条提示:

旧配置 转换结果
服务商变量(OPENAI_API_KEY、TTS_MINIMAX_API_KEY 等)和 server-providers.yml 部署服务商(在模型设置中只读)。服务商 ID 就是预设 ID,与旧的 provider ID 相同,只有联网搜索的 MiniMax(minimax-search)、联网搜索的豆包(doubao-search)和 Lemonade 图像(lemonade-image)例外
_MODELS 服务商的 models
DEFAULT_MODEL llm 的分配,作为工作区可以覆盖的默认值(不锁定)。agent 槽位和以前一样保持关闭
MODEL_FALLBACK DEFAULT_MODEL 的回退模型。没有 DEFAULT_MODEL 时会被丢弃并给出提示
第一个配置的 TTS、ASR、图像、视频和文档服务商,以及按旧优先级选出的联网搜索服务商 该能力的默认分配(图像服务商由 DEFAULT_IMAGE_PROVIDER 指定)
MODEL_ROUTES 不转换。 服务拒绝启动(见下文)
<CAP>_<VENDOR>_ENABLED=false 仍然生效:无论谁分配,该服务商都保持关闭

存在 openmaic.yml 时,服务商变量、server-providers.yml、DEFAULT_MODEL、MODEL_FALLBACK 和 MODEL_ROUTES 都不再参与模型解析,并会给出提示。<CAP>_<VENDOR>_ENABLED=false 仍然生效;要关闭某项能力,请改为把它的槽位设为 null。

环境变量。 迁移前:

bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=openai:gpt-5.5
MODEL_FALLBACK=anthropic:claude-sonnet-4-6
TTS_MINIMAX_API_KEY=...
TAVILY_API_KEY=tvly-...

迁移后(key 仍留在环境变量中):

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  minimax-tts:
    preset: minimax-tts
    apiKey: ${TTS_MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}

slots:
  llm:
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
  tts: minimax-tts
  webSearch: tavily

之后删除 DEFAULT_MODEL 和 MODEL_FALLBACK。服务商变量可以保留,作为 ${VAR} 引用的来源。_BASE_URL 对应服务商的 baseUrl(OPENAI_BASE_URL → openai 下的 baseUrl;直接写 URL,或在变量仍然设置时写 ${OPENAI_BASE_URL}),_MODELS 对应它的 models 列表。与转换得到的 DEFAULT_MODEL 一样,这里写出的 llm 是工作区可以修改的服务端默认值;如需对所有人固定,请加上 lock: [llm]。与转换结果不同,agent 现在会沿用 llm:如需保持 Pro 智能体关闭,请写 agent: null。

server-providers.yml。 迁移前:

yaml title="server-providers.yml"
providers:
  azure:
    apiKey: ...
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
pdf:
  alidocmind:
    accessKeyId: ...
    accessKeySecret: ...
web-search:
  searxng:
    baseUrl: http://searxng:8080

迁移后:

yaml title="openmaic.yml"
providers:
  azure:
    preset: azure
    apiKey: ${AZURE_OPENAI_API_KEY}
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
  alidocmind:
    preset: alidocmind
    credentials:
      accessKeyId: ${ALIDOCMIND_ACCESS_KEY_ID}
      accessKeySecret: ${ALIDOCMIND_ACCESS_KEY_SECRET}
  searxng:
    preset: searxng
    baseUrl: http://searxng:8080

slots:
  llm: azure:my-deployment
  document: alidocmind
  webSearch: searxng

server-providers.yml 的每个分组都对应使用相应预设的服务商(providers → 对话预设,tts、asr、pdf → 文档,image、video、web-search),accessKeyId/accessKeySecret 移到 credentials 下。

MODEL_ROUTES。 设置了 MODEL_ROUTES 但没有 openmaic.yml 的服务不会启动:

text
MODEL_ROUTES does not carry over to the model configuration: write the per-stage models as slots in openmaic.yml (or the file named by OPENMAIC_CONFIG) and remove MODEL_ROUTES

按阶段的路由与槽位并非一一对应,因此不会自动转换。请把每条路由改写成该阶段所属的槽位:

MODEL_ROUTES 阶段 槽位
web-search-query-rewrite course.research
scene-outlines-stream course.outline
agent-profiles course.agents
scene-content course.content
scene-content:slide、:quiz、:interactive、:pbl course.content.slide、.quiz、.interactive、.pbl
scene-actions course.actions
chat-adapter、quiz-grade、pbl-v2-runtime 及其 :instructor、:open-task、:evaluate、:simulator 变体 classroom
generate-classroom llm
maic-agent-driver agent
conversation-title agent.title
pbl-chat、maic-agent 无:这些阶段已移除,直接删掉

多个阶段共用一个槽位时,为该槽位指定它们应当共同使用的那个模型。路由里的 thinking 移到槽位的 thinking,maic-agent-driver 路由的 api 和 contextWindow 移到 agent 槽位。迁移前:

bash
DEFAULT_MODEL=openai:gpt-5.5
MODEL_ROUTES='{"scene-outlines-stream":"anthropic:claude-sonnet-4-6","scene-content:slide":{"model":"openai:gpt-5.4","thinking":{"effort":"low"}},"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'

迁移后:

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}

slots:
  llm: openai:gpt-5.5
  course.outline: anthropic:claude-sonnet-4-6
  course.content.slide:
    model: openai:gpt-5.4
    thinking: { effort: low }
  agent:
    model: openai:gpt-5.5
    api: openai-completions

一旦存在 openmaic.yml,服务商变量本身就不再配置任何东西:只靠变量配置的 TTS、图像、文档或网络搜索服务商(TTS_MINIMAX_API_KEY、IMAGE_SEEDREAM_API_KEY、PDF_MINERU_BASE_URL、TAVILY_API_KEY 等)不再被使用,除 webFetch 外,其他能力也不会得到默认服务商。请把用到的每个服务商写进 providers,并在 slots 中指定 tts、image、document、webSearch(以及 asr、video),做法同上面的环境变量示例;未指定的其他槽位保持未配置,除非某个工作区在模型设置中为它指定。

然后删除 MODEL_ROUTES、DEFAULT_MODEL 和 MODEL_FALLBACK。

Docker。 把文件挂载到应用旁边,key 仍放在 .env.local 中:

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

ACCESS_CODE —— 站点级访问密码

对共享部署(内部 demo、课堂),可以设置访问码,访客先输密码才能看到应用:

bash
ACCESS_CODE=your-secret-code

请使用足够长的随机值(至少 16 个字符),因为该密码是保护部署的唯一密钥。验证通过后会在 HTTP-only cookie 中保存一个签名令牌,有效期 7 天,由服务端强制校验,过期后需要重新验证。只有在受信任的反向代理之后设置 TRUST_PROXY_HEADERS=true(由代理覆盖 x-forwarded-for / x-real-ip)时才会限流:按客户端限流(每个客户端 60 秒内 10 次),验证成功会清空该客户端的计数;没有可信代理时应用无法把请求归因到具体客户端,因此完全不限流,保护依赖密码的长度和随机性。留空则关闭。

功能开关

功能开关的值为 true 或 1;除非另有说明,其他值视为关闭。NEXT_PUBLIC_* 开关会在构建时注入客户端,修改后需要重新构建:

bash
# MAIC Editor(Pro 模式)
NEXT_PUBLIC_MAIC_EDITOR_ENABLED=true

# Pi 播放态中的 PPT 与互动页课件引用(构建时开关;不影响编辑态引用)
NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED=true

# 职业教育任务引擎(服务端开关)
OPENMAIC_ENABLE_VOCATIONAL=true

# 显示职业教育实验开关
NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI=true

# 显示视频导出入口
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true

# 显示实验性 PPTX 导入入口
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true

课堂对话固定由 Pi 运行,需要支持工具/函数调用的模型:在课程模型配置中为课堂互动选择这样的模型,或在 openmaic.yml 中设置 classroom 槽位(或它继承的 llm 槽位)。Gemini 请使用原生 google 预设,不要使用 Google 的 OpenAI 兼容端点。 内置模型目录标明不支持工具调用的模型会在推理前被拒绝,返回 400 MODEL_CONFIG_INVALID(reason: tool_calling_required),课程模型配置也会在课堂互动槽位上注明这一要求。

旧版 director-graph 课堂对话已移除,NEXT_PUBLIC_PI_CHAT_ENABLED 不再选择对话链路。请从配置中删除该变量:production 构建只接受未设置、空字符串、true 或 1(精确匹配),其他任何值都会构建失败并给出迁移提示。使用 Docker Compose 时,服务的 env_file(.env.local)提供的是运行时值,不进入构建参数。

PPTX 导入目前仍是实验性入口,解析结果尚未完整接入课堂数据流。以上 NEXT_PUBLIC_* 变量都是构建时开关;Docker 部署还需要通过 build args 传入,不能只写在容器运行时环境变量中。

其他服务端选项

以下服务端选项也可以通过环境变量配置:

bash
# 场景内容并行生成;0 或未设置表示串行
PARALLEL_SCENE_CONCURRENCY=3

# 允许访问 localhost、内网等本地网络地址;仅用于自托管/内网部署
ALLOW_LOCAL_NETWORKS=true

# 可选的 MP4 渲染服务
RENDER_SERVICE_URL=http://render-service:9000

# 日志和推理
LOG_LEVEL=info
LOG_FORMAT=pretty
LLM_THINKING_DISABLED=false

旧版服务商环境变量

这些变量和 server-providers.yml 是早期版本的配置方式。在没有 openmaic.yml 时它们仍然有效(见从旧配置迁移),但已弃用,将在模型设置发布两个次版本后移除。使用 openmaic.yml 时,它们只作为 ${VAR} 引用的来源。DEFAULT_MODEL 和 MODEL_FALLBACK 见迁移一节;MODEL_ROUTES 已不再读取。

LLM 提供方

云端提供方通常使用以下三个环境变量:API key、base URL 和 model 列表。其中 API key 通常是必填的,base URL 和 model 列表可选;Azure OpenAI 需要配置资源 endpoint,Ollama 和 Lemonade 不需要 API key。

bash
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=                # 可选的 base URL 覆盖
OPENAI_MODELS=                  # 可选的模型白名单(逗号分隔)

支持的 provider 前缀:

前缀 提供方
OPENAI_ OpenAI
AZURE_OPENAI_ Azure OpenAI
ANTHROPIC_ Anthropic
GOOGLE_ Google Gemini
DEEPSEEK_ DeepSeek
QWEN_ 阿里 Qwen
KIMI_ Moonshot Kimi
MINIMAX_ MiniMax(默认用 Anthropic 兼容端点)
GLM_ 智谱 GLM
SILICONFLOW_ SiliconFlow
DOUBAO_ 豆包(字节跳动)
OPENROUTER_ OpenRouter
GROK_ xAI Grok
TENCENT_ 腾讯混元
TENCENT_HUNYUAN_ 腾讯混元(别名)
XIAOMI_ 小米 MiMo
MIMO_ 小米 MiMo(别名)
OLLAMA_ Ollama(本地)
LEMONADE_ Lemonade(本地)

Azure OpenAI 使用 deployment name 作为模型 ID:

bash
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=your-deployment-name

如需连接其他 OpenAI 兼容的 LLM 服务,请在 openmaic.yml 中声明一个带 baseUrl 和 models 的 openai-compatible 服务商,或在 设置 → 模型服务 → 语言模型 中用 添加服务商 添加。

Gemini 课堂互动请使用原生 google 预设:在设置 → 模型服务中添加 Google,并在课程模型配置中为课堂互动选择该模型。Pi 会提前拒绝 Google 官方 OpenAI 兼容端点(https://generativelanguage.googleapis.com/v1beta/openai)并提示迁移;兼容路线的工具调用支持由 #1556 跟踪。此检查仅识别该官方端点,无法识别任意第三方网关背后的上游。

本地模型(Ollama 和 Lemonade)

Ollama 和 Lemonade 不需要 API key;本地服务的 base URL 应写在服务端配置中,以通过 SSRF 校验:

bash
OLLAMA_BASE_URL=http://localhost:11434/v1
# LEMONADE_BASE_URL=http://localhost:13305/v1

如需限制可用模型,可使用 OLLAMA_MODELS 或 LEMONADE_MODELS 指定模型白名单。

TTS 提供方

服务端 TTS 提供方使用 TTS__API_KEY,可选 TTS__BASE_URL。

bash
# 豆包 TTS(火山引擎 Seed-TTS,原生 MP3)
TTS_DOUBAO_API_KEY=appId:accessKey
TTS_DOUBAO_BASE_URL=       # 可选覆盖

# Qwen TTS
TTS_QWEN_API_KEY=
TTS_QWEN_BASE_URL=         # 可选覆盖

# 任何 OpenAI 兼容的 TTS 端点
TTS_OPENAI_API_KEY=
TTS_OPENAI_BASE_URL=

# VoxCPM2(自托管 TTS,支持声音克隆,详见 VoxCPM2 单独页)
TTS_VOXCPM_BASE_URL=http://localhost:8000

支持的 TTS 前缀包括 TTS_OPENAI_、TTS_AZURE_、TTS_GLM_、TTS_QWEN_、TTS_MINIMAX_、TTS_DOUBAO_、TTS_ELEVENLABS_、TTS_GOOGLE_、TTS_VOXCPM_ 和 TTS_LEMONADE_。本地 Lemonade TTS 和 VoxCPM2 不需要 API key。浏览器原生 TTS 不需要服务端配置。

管理员可以使用 TTS__ENABLED=false 在服务端强制关闭某个 TTS 提供方。VoxCPM2(自托管 TTS + 声音克隆)请见单独的 VoxCPM2 章节。

如需使用其他 OpenAI 兼容的 TTS 端点,请在 openmaic.yml 中声明一个带该 baseUrl 的 openai-tts 服务商,并把 tts 槽位分配给它。工作区不能在模型设置中添加自定义 TTS 端点。

ASR(语音转文字)

bash
# OpenAI Whisper
ASR_OPENAI_API_KEY=
ASR_OPENAI_BASE_URL=       # 可选覆盖

# Qwen ASR
ASR_QWEN_API_KEY=
ASR_QWEN_BASE_URL=         # 可选覆盖

# Azure ASR
ASR_AZURE_API_KEY=
ASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com

# FunASR(本地,不需要 key)
ASR_FUNASR_BASE_URL=http://localhost:8000/v1

# Lemonade ASR(本地,不需要 key)
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1

使用 funasr-server --device cuda 启动 OpenAI 兼容服务。它在 /v1/audio/transcriptions 接收 WAV,并可在模型选择器中使用 SenseVoiceSmall、Paraformer 和 Fun-ASR-Nano。

浏览器原生 ASR 不需要服务端配置。

如需使用其他 OpenAI 兼容的 ASR 端点,请在 openmaic.yml 中以 openai-whisper 预设和它的 baseUrl 声明该服务,并把 asr 槽位分配给它。工作区不能在模型设置中添加自定义 ASR 端点:

yaml title="openmaic.yml"
providers:
  my-asr:
    preset: openai-whisper
    apiKey: ${MY_ASR_API_KEY}
    baseUrl: https://asr.example.com/v1

slots:
  asr: my-asr:whisper-1

图像生成提供方

图像生成提供方使用 IMAGE__API_KEY,可选 IMAGE__BASE_URL。支持的前缀包括:

IMAGE_OPENAI_、IMAGE_SEEDREAM_、IMAGE_QWEN_IMAGE_、IMAGE_NANO_BANANA_、IMAGE_MINIMAX_、IMAGE_GROK_、IMAGE_GLM_ 和 IMAGE_LEMONADE_。

GLM 图像(智谱 CogView)使用 CogView 模型(cogview-3-flash、cogview-4)生成图片:

yaml title="openmaic.yml"
providers:
  glm-image-service:
    preset: glm-image
    apiKey: ${IMAGE_GLM_API_KEY}
    # 可选:
    baseUrl: https://open.bigmodel.cn/api/paas/v4

slots:
  image: glm-image-service:cogview-3-flash

也可以通过环境变量配置:

bash
IMAGE_GLM_API_KEY=
# 可选:
IMAGE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

Lemonade 是本地服务,不需要 key:

bash
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1

ComfyUI Image 不需要 API key。它运行在你自己的网络中,因此由部署配置:在 openmaic.yml 中用 ComfyUI 服务的地址声明它,并把 image 槽位分配给它。

yaml title="openmaic.yml"
providers:
  comfyui:
    preset: comfyui-image
    baseUrl: http://host.docker.internal:8188

slots:
  image: comfyui

把以 API 格式导出的工作流 JSON 放到 OpenMAIC 的 public/ 目录中,文件名需匹配 comfyui-*.json 或包含 workflow;OpenMAIC 会自动发现这些文件并作为工作流提供。使用 Docker 时,请在构建镜像前加入工作流,或把单个工作流文件挂载到容器内的 /app/public/。由于该服务商由部署声明,运行在宿主机或内网中的 ComfyUI 不需要 ALLOW_LOCAL_NETWORKS。

视频生成提供方

视频生成提供方使用 VIDEO__API_KEY,可选 VIDEO__BASE_URL。支持的前缀包括:

VIDEO_SEEDANCE_、VIDEO_KLING_、VIDEO_SORA_、VIDEO_MINIMAX_、VIDEO_GROK_ 和 VIDEO_HAPPYHORSE_。

文档和媒体解析

课程材料的具体格式取决于所选解析器。当前支持文本、PDF、Office 文档、图片以及部分音视频格式;不同 provider 支持的格式和能力不同。

bash
# MinerU 自托管
PDF_MINERU_BASE_URL=http://localhost:8888

# MinerU 自托管的可选后端
PDF_MINERU_BACKEND=pipeline

# MinerU Cloud
PDF_MINERU_CLOUD_API_KEY=
PDF_MINERU_CLOUD_BASE_URL=https://mineru.net/api/v4

# AliDocMind(使用阿里云 AccessKey,而不是单独的 API key)
ALIDOCMIND_ACCESS_KEY_ID=
ALIDOCMIND_ACCESS_KEY_SECRET=
ALIDOCMIND_BASE_URL=       # 可选覆盖

unpdf 内置于 OpenMAIC,可用于基础 PDF 解析。Office 文档、图片和需要 OCR、表格、公式或版面分析的材料,应选择兼容的 MinerU 或 AliDocMind provider。

AliDocMind 的文档解析支持 PDF、DOCX、PPTX、XLSX,以及 PNG、JPG/JPEG、BMP、GIF。当前音视频材料仅支持通过 AliDocMind 解析:视频格式为 MP4、MOV、AVI、MKV、WMV,音频格式为 MP3、WAV、AAC;不支持 M4A。

联网搜索

配置 Tavily、Exa、Firecrawl、Bocha、Brave、Baidu、SearXNG 或 MiniMax:

bash
TAVILY_API_KEY=
TAVILY_BASE_URL=           # 可选覆盖

EXA_API_KEY=
EXA_BASE_URL=https://api.exa.ai  # 可选覆盖

FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev  # 可选覆盖

BOCHA_API_KEY=
BOCHA_BASE_URL=            # 可选覆盖

BAIDU_API_KEY=
BAIDU_BASE_URL=https://qianfan.baidubce.com  # 可选覆盖

# 自托管 SearXNG,不需要 API key
SEARXNG_BASE_URL=

WEB_SEARCH_MINIMAX_API_KEY=
WEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com  # 可选覆盖

Brave 和 SearXNG 不需要 API key。联网搜索没有逐次生成的开关:只要工作区的 webSearch 槽位能解析就会运行;关闭它(在 openmaic.yml 中写 webSearch: null,或在 设置 → 课程模型配置 中关闭搜索开关)会影响该工作区之后的生成。Grok 的联网搜索通过 Grok LLM 的搜索工具提供,不是独立的联网搜索 provider。

server-providers.yml

已被 openmaic.yml 取代。两者同时存在时,只有 openmaic.yml 用于配置模型。

除了环境变量,你也可以把代码中已注册 provider 的配置放在项目根目录下的 server-providers.yml 文件里。服务端启动时加载,结构与环境变量分类对应:

yaml title="server-providers.yml"
providers:
  openai:
    apiKey: sk-...
    baseUrl: https://api.openai.com/v1
    models:
      - gpt-5.5
  anthropic:
    apiKey: sk-ant-...

tts:
  doubao-tts:
    apiKey: appId:accessKey

asr:
  openai-whisper:
    apiKey: sk-...

pdf:
  mineru:
    baseUrl: http://localhost:8888
  alidocmind:
    accessKeyId: your-access-key-id
    accessKeySecret: your-access-key-secret

image:
  seedream:
    apiKey: ...

video:
  seedance:
    apiKey: ...

web-search:
  tavily:
    apiKey: tvly-...
  exa:
    apiKey: ...
  firecrawl:
    apiKey: fc-...
  searxng:
    baseUrl: http://localhost:8080

环境变量会逐字段覆盖 YAML 中的同名 provider 配置。键名必须使用代码中已注册的 provider ID,例如 openai、doubao-tts、openai-whisper、mineru、alidocmind、seedream、seedance、exa、tavily 或 searxng;未知 ID 不能在这里注册为自定义 LLM provider。

服务端托管服务商的模型授权

对于使用部署凭据把所选模型发往上游的服务商,请在 openmaic.yml 中设置显式的 models 列表(providers..models),或设置对应的 *_MODELS 环境变量 / server-providers.yml 中该部分的 models 列表。这适用于对话、图像、视频、语音合成、语音识别和基于模型的联网搜索。模型 ID 在加载配置时去掉首尾空白后精确匹配,区分大小写。确定性的适配器别名按声明的模型 ID 授权。请求未列出的模型会返回 HTTP 403,错误码 PROVIDER_DISABLED,消息为“This model is not authorized for server-managed credentials”。

破坏性变更: 没有显式列表时,只能使用部署的默认/回退模型,以及该能力的槽位所引用的主模型/回退模型。在旧版配置下,DEFAULT_MODEL 和 MODEL_FALLBACK 授权对话模型。只写服务商的媒体槽位只授权该适配器的默认模型,不授权该预设的整个模型目录。工作区的分配无法扩大部署的授权范围。音色注册和合成也必须授权为音色兼容性而选择的模型。

迁移时,列出用户应能选择的每个模型,重启服务,并更新不再获授权的工作区选择。对于有凭据但没有列表的服务商,启动日志会指出需要添加的设置。用户自己的服务商凭据,以及 Ollama、Lemonade 这类无需 key 的本地服务商不受影响。未配置凭据的服务商不受限制,因此不要把它指向会自行添加上游凭据的网关(例如内部的 OpenAI 兼容代理):请给该网关一个 key,把它配置为带 models 列表的有凭据服务商,或在网关本身限制模型。没有模型选择的文档解析器和搜索服务(如 Tavily)不需要模型列表。

yaml
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
    models: [gpt-5.4-mini]
slots:
  llm: openai:gpt-5.4-mini

旧版等价写法:OPENAI_API_KEY=... 和 OPENAI_MODELS=gpt-5.4-mini。媒体能力使用能力前缀,例如 IMAGE_SEEDREAM_MODELS、VIDEO_SEEDANCE_MODELS、TTS_OPENAI_MODELS、ASR_OPENAI_MODELS 和 WEB_SEARCH_CLAUDE_MODELS。

DeepSeek 联网搜索与网页抓取

deepseek 预设在对话、联网搜索和托管 Markdown 提取之间共享 DEEPSEEK_API_KEY 和 API 基础地址。搜索使用 POST /v1/tools/search,最多返回 50 条结果,查询按 Unicode code point 截断到 128 个字符,优先使用 summary,为空时回退到 snippet。抓取使用 POST /v1/tools/fetch,读取 content 和非空的可选 title。

旧版配置需要设置 WEB_SEARCH_DEEPSEEK_ENABLED=true,或在 server-providers.yml 中显式添加 web-search.deepseek。仅配置对话凭据不会启用搜索或替换已有搜索默认项。WEB_SEARCH_DEEPSEEK_ENABLED=false 会强制禁用搜索。存在 openmaic.yml 时,旧版变量不能指定槽位,必须显式声明服务商并指定 webSearch 和/或 webFetch。

The default API base is https://api.deepseek.com/v1. On api.deepseek.com, every base path (including /beta) is normalized to /v1 before appending /tools/search or /tools/fetch. Custom gateway bases preserve their configured path prefix; a bare root gets /v1 first. Custom endpoints belong in deployment configuration, while client and workspace overrides remain restricted to official endpoints. The default fetch reader remains builtin. See DeepSeek official pricing for current request charges. Failed requests and searches with no results are not billed.

yaml
providers:
  deepseek:
    preset: deepseek
    apiKey: ${DEEPSEEK_API_KEY}
    baseUrl: https://api.deepseek.com/v1
slots:
  webSearch: deepseek
  webFetch: deepseek

Kimi 对话与联网搜索

声明一个 Kimi 账号,并将两个槽位都分配给它:

yaml
providers:
  kimi:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    # 可选:国际版开放平台使用 https://api.moonshot.ai/v1。
    # baseUrl: https://api.moonshot.ai/v1
slots:
  llm: kimi:kimi-k3
  webSearch: kimi

使用 Coding Plan 密钥时,请设置 preset: kimi-coding-plan 和 llm: kimi:kimi-for-coding;其默认地址为 https://api.kimi.com/coding/v1。开放平台和 Coding Plan 密钥必须使用各自对应的基础 URL。 在旧版配置中,只有显式启用后,KIMI_API_KEY 和可选的 KIMI_BASE_URL 才会为搜索提供凭据:设置 WEB_SEARCH_KIMI_ENABLED=true,或在 server-providers.yml 中添加 web-search.kimi(例如设置 enabled: true)。仅有对话凭据不会启用搜索,也不会改变默认搜索服务商。即使槽位已分配给 Kimi,WEB_SEARCH_KIMI_ENABLED=false 仍会禁用 Kimi 搜索。WEB_SEARCH_KIMI_ENABLED 是 Kimi 搜索的唯一开关。 KIMI_MODELS 仅适用于对话。旧版对话和搜索账号冲突时会产生警告,并跳过搜索条目;如需使用不同账号,请在 openmaic.yml 中声明不同的服务商 ID。 存在 openmaic.yml 时,请显式声明服务商并分配槽位。设置和首次使用向导允许通过同一账号使用两项能力。

端点选择取决于基础路径:去掉末尾斜杠后,以 /coding/v1 结尾的路径使用 /search,其他路径使用 /tools/search_pro。部署配置的自定义代理必须保留这一布局。向导只为官方账号地址推荐搜索,因此工作区自定义对话代理不会自动获得搜索分配。

存在 openmaic.yml 时,请声明 providers.: { preset: kimi } 并分配 slots.webSearch: ;旧版变量 WEB_SEARCH_KIMI_ENABLED 无法分配该槽位。

网页抓取服务槽位

webFetch 决定智能体的 fetch_url 工具使用哪个读取服务。在旧版配置、空配置和 YAML 配置中,默认使用 builtin;即使 lock: all 下没有指定该根槽位也是如此。显式设置 webFetch: null 会关闭抓取,并从后续运行中移除该工具及其提示。每次请求只使用一个服务商,不会回退或升级到其他服务。无效的服务商引用或被策略拒绝的工作区端点会在智能体启动前的能力解析阶段导致整个智能体运行失败,与 webSearch 一致。这属于配置错误,并非 fetch_url 返回的失败结果,也不会自动改用内置读取服务。

服务商 凭据 内容提取
builtin 无需凭据 本地 Readability/Turndown 读取服务;PDF 使用 document 槽位
kimi 已有 Kimi 服务商的密钥和基础地址 托管 Markdown 提取服务,也接收 PDF 网址
deepseek 现有 DeepSeek 对话密钥和 API 基础地址 托管 Markdown 提取
firecrawl Firecrawl API 密钥(preset: firecrawl) 托管 Markdown 提取服务,返回页面主体内容,也接收 PDF 网址
yaml
providers:
  reader:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    baseUrl: https://api.moonshot.cn/v1
slots:
  webFetch: reader

kimi 和 kimi-coding-plan 预设都提供 webFetch。使用 Coding Plan 凭据时,请指定 preset: kimi-coding-plan,其默认基础地址为 https://api.kimi.com/coding/v1;显式配置的基础地址会保留。Moonshot 开放平台地址(https://api.moonshot.cn/v1 和 https://api.moonshot.ai/v1)使用 /tools/fetch,Coding Plan 使用 /fetch。两者都发送带有 { "url": ... } 的 POST 请求,并使用 Bearer 身份验证。适配器接受 markdown 或 content。维护者使用真实密钥测试了 POST https://api.kimi.com/coding/v1/fetch,确认响应包含 { markdown, title, url };国际端点的可用性仍未验证。请求遵循服务商的 proxy 设置和标准代理环境变量。

DeepSeek 也通过 deepseek 预设提供托管 Markdown 提取,复用对话密钥和 API 基础地址。按上面的示例显式指定 webFetch,调用 /v1/tools/fetch,并保留部署配置中的网关地址。超时和仅对传输错误重试一次的行为与 Kimi 相同,不重试 HTTP 错误或无效响应。

在 设置 → 模型服务 → 网页抓取 中使用已有服务商凭据。该密钥与同一服务商的对话服务共享,修改或移除密钥也会影响对话。在 课程模型配置 → 网页抓取 中选择读取服务。管理员锁定后,卡片为只读。首次设置会保留内置默认值,直到明确选择其他读取服务。工作区凭据可使用官方 API 基础地址;自定义抓取地址和代理必须由管理员在 openmaic.yml 中声明。

与 kimi 不同,firecrawl 使用单独的密钥:在 openmaic.yml 中声明 preset: firecrawl 的服务商并设置 apiKey(例如 ${FIRECRAWL_API_KEY}),或在 设置 → 模型服务 → 网页抓取 中填写密钥。它以 Bearer 认证发送 POST /v2/scrape,以 Markdown 返回页面主体内容。默认基础地址为 https://api.firecrawl.dev;自托管 Firecrawl 的基础地址须由管理员在 openmaic.yml 中声明。

同一个 firecrawl 服务商也提供联网搜索(POST /v2/search):一把密钥和一个基础地址同时用于两者,因此可以把同一个服务商同时指定给 slots.webSearch 和 slots.webFetch。旧版的 FIRECRAWL_API_KEY 和 FIRECRAWL_BASE_URL 变量配置的就是这个服务商;在把 webFetch 指定给它之前,网页抓取仍使用 builtin。

网址转发给托管服务之前,必须通过会话网址授权、严格网址规范化和全部本地 DNS 地址检查。接收内容前,也会对服务返回的最终网址执行相同检查。托管服务控制其自身的 DNS 和中间重定向,本地检查无法固定其网络连接。返回的文本仍是不可信数据,经过验证页面检测、字节限制和截断标记处理,并沿用网页素材持久化及 {trusted, untrusted} 结果结构。


Packages Docs Content Docs Configuration.Zh Tw (`packages/docs/content/docs/configuration.zh-tw.mdx`)


title: 設定說明

OpenMAIC 有三個設定來源,各自只負責一件事:

來源 由誰撰寫 內容 何時生效
openmaic.yml 維運人員 供應商、伺服器端預設指派、鎖定的插槽,以及使用者能否新增自己的 Key 啟動時(修改後需重新啟動)
Web 端的模型設定 工作區的使用者 使用者自己的供應商,以及 openmaic.yml 未鎖定插槽的指派,儲存在資料庫中 立即生效
環境變數(.env.local、容器 Secret) 維運人員 openmaic.yml 引用的金鑰,以及其他所有伺服器端選項 啟動時

DATABASE_URL is required; other environment variables are optional (see Deployment). Providers that use server credentials for model selection also need authorized model lists, as described below. See .env.example for environment variables, openmaic.example.yml for a sample model configuration, and Supported models for preset and model IDs.

早期版本只能透過供應商環境變數、server-providers.yml、DEFAULT_MODEL 和 MODEL_ROUTES 設定模型。除了 MODEL_ROUTES,這些方式目前仍可使用,但已棄用,請見從舊設定遷移。

模型與供應商

基本概念

  • 供應商(provider):伺服器端可以呼叫的一個帳號,例如一把 OpenAI key、一個 MiniMax Token Plan 或一個自架的 OpenAI 相容服務。每個供應商有一個你自訂的 ID,以及一個範本(preset),說明它是哪家廠商、涵蓋哪些能力。
  • 插槽(slot):產品中用到 AI 的一處地方,例如課程大綱、投影片內容、語音合成或網路搜尋。每種能力的插槽構成一棵樹;沒有指派的插槽沿用父插槽。
  • 指派(assignment):插槽使用哪個供應商的哪個模型(provider:model),可以附帶呼叫參數和備援模型;也可以寫 null 關閉該插槽。

一項能力是否可用,只取決於它的插槽能否解析到一個指派,沒有另外的開關。

openmaic.yml

伺服器端從啟動目錄(儲存庫根目錄;Docker 映像檔中為 /app)讀取 openmaic.yml。設定 OPENMAIC_CONFIG 可以改用其他路徑;相對路徑同樣以啟動目錄為基準解析,而且此時檔案必須存在。沒有該檔案時,伺服器端會使用 Web 端的設定,以及仍在設定中的舊版變數。

該檔案只在啟動時讀取一次,修改後需要重新啟動服務。服務啟動前會完整驗證檔案:未設定的 ${VAR}、未知的範本或插槽、引用了未宣告的供應商,或供應商不提供插槽所需的能力,都會讓服務拒絕啟動,並逐條列出問題所在的位置:

text
[boot] Invalid server configuration; the server will not start: Invalid model configuration in /app/openmaic.yml:
  - providers.openai.apiKey: environment variable OPENAI_API_KEY is not set
  - slots.course.outlines: unknown slot

不要把金鑰直接寫進檔案:用 ${VAR} 引用,並在環境變數中設定。來自環境變數的值不會出現在這些錯誤訊息中。

完整範例

yaml title="openmaic.yml"
providers:
  # <供應商 ID>:小寫字母、數字和連字號
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}      # 啟動時從環境變數讀取
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  openai-image:                    # 同一把 key,用於圖像生成
    preset: openai-image
    apiKey: ${OPENAI_API_KEY}
  minimax:                         # Token Plan:對話、圖像、影片、TTS、搜尋
    preset: minimax
    apiKey: ${MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}
  local:                           # 任何 OpenAI 相容服務
    preset: openai-compatible
    baseUrl: http://ollama:11434/v1
    models: [qwen3:8b]

slots:
  llm: openai:gpt-5.5              # 下方所有對話插槽的預設值
  course.outline:
    model: anthropic:claude-sonnet-4-6
    fallback: openai:gpt-5.4-mini  # 遇到可重試的失敗時改用一次
  course.content.slide:
    model: minimax:MiniMax-M3
    thinking: { enabled: false }
    fallback: minimax:MiniMax-M2.7
  classroom: local:qwen3:8b        # 模型 ID 可以包含冒號
  agent:
    model: openai:gpt-5.5          # 需要支援工具呼叫
    api: openai-completions
  tts: minimax:speech-2.8-turbo
  image: openai-image:gpt-image-2
  webSearch: tavily                # 只寫供應商:使用它的預設模型
  video: null                      # 預設關閉;使用者可以自行開啟

lock: [tts]                        # 對所有人固定,連同其子樹
allowUserKeys: false               # 使用者只能在這些供應商中選擇,不能自行新增

使用這份檔案時,slots 下寫出的插槽是伺服器端預設值:使用者可以在模型設定中逐一修改,只有被 lock 固定的 tts 對所有人不可變更。檔案沒寫的對話插槽(例如 course.agents)沿用父插槽(llm);沒寫的能力根插槽(例如 asr 或 document)在使用者指派之前維持未設定。設定 allowUserKeys: false 後,使用者只能在這裡宣告的供應商中選擇。

供應商

providers 下的每一項宣告一個供應商。鍵名就是它的 ID,供插槽引用:1 到 63 個小寫字母、數字和連字號,以字母或數字開頭。

欄位 是否必填 意義
preset 是 使用哪個內建範本,請見支援的模型 → 範本。
apiKey 通常需要 API key。不需要 key 的服務(本機服務、Brave、SearXNG、unpdf)可以省略。
baseUrl 部分範本需要 取代範本內建端點的位址。openai-compatible、azure、searxng 和 mineru 必填。
models For server-managed credentials Authorized model ids. Deployment slot defaults/fallbacks for the capability are also authorized; catalogues and workspace assignments do not grant additional models.
proxy 否 該供應商請求使用的 HTTP 代理位址。
credentials 部分範本需要 不使用單一 key 的服務所需的金鑰對。AliDocMind 使用 accessKeyId 和 accessKeySecret。
options 否 非機密、供應商特有的設定,例如 voxcpm-tts 的 backend。名稱由字母、數字、_ 和 - 組成;值為字串、數字或布林值(不能巢狀對應或清單),可以使用 ${VAR}。模型設定會顯示 options,因此絕對不要在其中放 key。僅限部署:模型設定無法設定它們。

大多數範本只涵蓋一種能力,與內建 registry 一一對應:openai 範本只有對話,OpenAI 的圖像、語音合成和語音辨識分別是 openai-image、openai-tts 和 openai-whisper 範本。每種能力宣告一個供應商;同一把 key 涵蓋多種能力時,引用同一個 ${VAR} 即可。Token Plan 範本(minimax、tokendance、volcengine-ark、kimi-coding-plan)用一把 key 涵蓋多種能力,每種能力使用該方案自己的端點。供應商上的 baseUrl 會取代它涵蓋的所有能力的端點,因此只有當 Token Plan 的全部能力都一起搬移時,才為它設定 baseUrl。

Amazon Bedrock(preset: bedrock)使用伺服器端的 AWS 憑證鏈(環境變數、profile 或 role)簽署請求,區域取自 BEDROCK_REGION、AWS_REGION 或 AWS_DEFAULT_REGION;apiKey 是選填的 bearer token。

插槽

插槽 能力 父插槽 作用
llm chat — 預設對話模型:下方任何插槽都不涵蓋的對話呼叫
course.research chat llm 課程研究時改寫網路搜尋查詢
course.outline chat llm 場景大綱
course.agents chat llm 老師和同學智慧體的人設
course.content chat llm 場景內容(沒有專屬插槽的場景類型)
course.content.slide chat course.content 投影片場景
course.content.quiz chat course.content 測驗場景
course.content.interactive chat course.content 互動場景
course.content.pbl chat course.content 專題式學習(PBL)場景
course.actions chat llm 場景動作(智慧體在每個場景說什麼、做什麼)
classroom chat llm 課堂對話、測驗評分和 PBL 執行階段
agent chat llm Pro 工作台智慧體。需要工具呼叫能力。 另外支援 api 和 contextWindow
agent.title chat agent Pro 工作階段標題。只能在 openmaic.yml 中設定,模型設定中不顯示
tts tts — 語音合成
asr asr — 語音辨識(語音輸入、影音逐字稿)
image image — 圖像生成
video video — 影片生成
webFetch webFetch — 網頁內容擷取(預設:builtin)
webSearch webSearch — 網路搜尋
document document — 文件解析服務(MinerU、MinerU Cloud、AliDocMind)。內建的 PDF 文字擷取不需要服務

插槽的解析方式:

  • 從呼叫所在的插槽開始,逐層往上直到能力根插槽,遇到的第一個指派生效。因此父插槽的指派就是整棵子樹的預設值;修改父插槽,會同時改變所有沒有自己指派的子插槽。
  • 在鎖定的子樹內(該插槽或其祖先被 lock 列出,或使用 lock: all),只看 openmaic.yml:往上查找到被鎖定的插槽為止,檔案中寫出的第一個指派生效。其他位置以使用者的選擇為準:先從該插槽往上直到根插槽查找工作區自己的指派,只有完全沒有時,才同樣從該插槽往上查找 openmaic.yml 寫的預設值。因此,工作區設定了父插槽時,沒有被工作區改過的子插槽會跟隨它,即使伺服器在子插槽上寫了預設值;伺服器的值只是預設值。
  • null 會停止往上查找:該插槽,以及其下所有沒有自己指派的插槽,都會被關閉。
  • 一直到根插槽都沒有指派時,該能力處於未設定狀態。呼叫會報錯並指出插槽名稱,不會退回任何廠商。
  • 需求(requirement)屬於宣告它的插槽,依該插槽最終解析到的模型檢查,無論是自己指派還是繼承而來。agent 需要支援工具呼叫;若內建模型目錄表明它的模型不支援,智慧體會拒絕執行,而不是勉強湊合。目錄中沒有的模型允許使用。agent.title 沒有需求,因此可以使用較小的模型。

指派

指派有四種寫法:

yaml
slots:
  llm: openai:gpt-5.5            # 供應商:模型
  webSearch: tavily              # 只寫供應商:使用它的預設模型
  course.outline:                # 帶參數的物件
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
    thinking: { effort: low }
  video: null                    # 關閉

物件寫法支援以下欄位:

欄位 意義
model 必填。供應商:模型;對話以外的插槽也可以只寫 供應商。
fallback 改用一次的備援模型,寫法同上。僅適用於語言模型插槽,請見備援模型。
thinking 對話模型的推理參數:enabled(true/false)、mode(default、disabled、enabled、auto)、effort(none、minimal、low、medium、high、xhigh、max)、level(minimal、low、medium、high)、budgetTokens、excludeReasoningOutput。各供應商轉接器只套用其 API 支援的部分。
api 僅 agent:傳輸協定,openai-completions(預設)或 openai-responses。
contextWindow 僅 agent:壓縮上下文時假定的視窗大小,用於比模型目錄中的值更小的情況。

agent 插槽不能設定 thinking.effort:它的工具呼叫傳輸無法同時使用推理強度和函式工具。

只寫供應商 ID 的引用

不帶 :model 的引用代表該供應商的預設模型:Token Plan 為該能力推薦的模型,否則是內建 registry 的預設模型。搜尋和文件服務大多沒有可選的模型,所以通常寫成 webSearch: tavily 或 document: mineru-cloud。對話插槽必須寫明模型,llm: openai 會在啟動時被拒絕。

備援模型

備援模型只適用於語言模型插槽,也就是 llm 及其下的所有插槽。在 tts、asr、image、video、webSearch、webFetch 或 document 插槽上寫 fallback 會被拒絕:openmaic.yml 在啟動時拒絕,模型設定在儲存時拒絕。

當主模型的呼叫在自身重試之後仍以可重試的方式失敗時,會改用插槽的 fallback 再試一次。可重試的失敗包括:SDK 判定的暫時性錯誤、逾時、網路錯誤、限流(429)、容量不足(503)或輸出為空。供應商明確回傳額度用盡時(OpenAI 相容格式的額度錯誤碼,例如 insufficient_quota,不論 HTTP 狀態碼),不會在主模型上重試,呼叫會直接改用備援模型。內容安全拒絕和其他 4xx 錯誤不會觸發備援。備援對一般呼叫和串流呼叫都有效,並沿用插槽的 thinking 參數。

備援模型屬於指派,隨指派一起被繼承:沒有自己指派的 course.content.slide 會使用 course.content 的模型及其備援模型。有需求的插槽不會改用不符合需求的備援模型。

關閉某項能力

在它的插槽上寫 null。video: null 預設關閉影片生成,使用者仍可在模型設定中開啟;再加上 lock: [video] 即對所有人維持關閉。course.content.pbl: null 只關閉 PBL 場景生成,course.content 的其他部分照常運作。對已關閉插槽的呼叫會立即失敗,媒體相關介面回傳 403,而不是改用其他廠商。

預設值、鎖定與模型設定

openmaic.yml 在 slots 下寫出的內容是伺服器端的預設值:使用者可以在模型設定中修改,卡片上會標示伺服器預設,恢復伺服器預設會撤銷使用者的修改。在 lock 下列出的插槽由管理員固定,對所有人生效,並連同整棵子樹一起固定:

yaml
lock: [llm, video]   # 這些插槽及其下所有插槽
# lock: all          # 所有插槽
  • lock 列出的每個插槽都必須在 slots 中寫出(null 也算:video: null 加 lock: [video] 會對所有人關閉影片)。未知插槽,或 slots 中沒有寫出的鎖定插槽,會讓服務在啟動時拒絕執行,並指出其位置(lock.1: ...)。
  • 鎖定的插槽同時固定其子插槽:使用 lock: [llm] 時,任何使用者都不能設定 course.outline 或其他語言模型插槽,它們全部只依 openmaic.yml 解析。
  • lock: all 固定所有插槽,無論是否寫出;slots 沒寫的能力根插槽因此對所有人都是未設定。但未指定的 webFetch 仍使用 builtin;設定 webFetch: null 可將其關閉。
  • null、伺服器端預設值與鎖定可以組合:只寫 video: null 時預設關閉,使用者可以開啟;加上 lock: [video] 後維持關閉。

設定介面會依照設定呈現三種形態之一:

形態 條件 顯示內容
自行設定 開啟 allowUserKeys(預設) Token Plan、模型服務和課程模型設定
選擇模型 allowUserKeys: false 只顯示課程模型設定,在 openmaic.yml 宣告的供應商中選擇
由管理員設定 所有插槽都被鎖定(lock: all) 課程模型圖,每張卡片皆為唯讀(顯示其模型,或已關閉、未設定)

所有控制項遵循同一條規則:只有使用它能改變什麼時才顯示。模型服務中某個能力的分頁及其新增服務入口,只在該能力還有使用者可設定的插槽時出現;Token Plan 只在某個方案還能填入未鎖定的插槽時出現,連接方案也絕不會改動鎖定的插槽。課程模型設定中被鎖定的卡片顯示為一行唯讀內容,並標示由管理員固定。首頁的模型選擇器只在預設模型可以修改時出現(llm 未被鎖定,且有供應商提供語言模型)。

Web 端的模型設定讀寫目前工作區的同一棵樹:

  • Token Plan:連接方案會用該 key 新增方案的供應商,並為方案推薦、且仍未指派的插槽填入模型。解除連接會刪除該供應商,原本使用它的插槽重新跟隨父插槽。
  • 模型服務:每種能力一個分頁(語言模型、圖像、影片、語音合成、語音辨識、文件解析、網路搜尋),列出各自的服務。來自 openmaic.yml 的供應商以唯讀方式顯示,不顯示任何關於 key 的資訊。工作區設定的服務會成為工作區供應商:key 只能寫入,只顯示類似 …abcd 的遮罩;自訂位址僅限語言模型(Azure 語音服務使用其官方區域位址,例如 https://<region>.tts.speech.microsoft.com);自架服務與金鑰對需在 openmaic.yml 中設定。測試按鈕在伺服器端檢查已儲存的供應商。
  • 課程模型設定:主線模型就是 llm 插槽,管線的每個環節對應自己的插槽(跟隨主線即該環節沒有自己的指派);圖像、影片、語音、搜尋和轉寫的開關會把對應插槽設為 null。被鎖定的插槽顯示為一行唯讀內容;使用伺服器端預設值的插槽在使用者修改後提供恢復伺服器預設。

早期版本儲存在瀏覽器中的供應商設定(供應商及其 key 和 Base URL、所選模型、Token Plan 連接狀態,以及每種能力的選擇)會一次性匯入工作區。匯入不會取代工作區既有的設定或 openmaic.yml 鎖定子樹中的插槽,並會略過被 allowUserKeys: false 或驗證拒絕的項目。收到 2xx 回應後,只有工作區已持有的內容會從瀏覽器中刪除:伺服器略過的每個項目(媒體服務的自訂位址、工作區不可使用的預設、與部署宣告重名的供應商 id 等)都會連同 key 保留在此瀏覽器中,不會再次傳送,提示一次,並在「設定 → 模型服務」中連同原因列出,直到重新設定或被捨棄。帶官方區域位址的 Azure 語音供應商會被匯入。收到 400 時整份匯入內容被拒絕:不會再次傳送,其中各項目會連同 key 保留在此瀏覽器中,並與被略過的項目一起列出;匯入內容無法讀取時會被捨棄。只有在 401、404、409、5xx 或網路錯誤時,匯入內容才會保留在瀏覽器中,供之後重試。明確關閉過的語音輸入會變為 asr: null;在已有可用供應商時關閉的語音講解、圖像或影片會變為 tts: null、image: null 或 video: null(openmaic.yml 鎖定的插槽保持不變)。以下內容需要手動重新設定:依階段設定的模型(在課程模型設定中,或在 openmaic.yml 的 slots 中)、推理設定(插槽上的 thinking)、自訂語音合成與語音辨識供應商以及 AliDocMind 的金鑰對(在 openmaic.yml 中;它們會保留在瀏覽器中,與被略過的項目一起列出)、VoxCPM 後端(options.backend),以及百度搜尋子來源(使用伺服器端預設值)。關閉的研究開關不會遷移(它只停止課程研究,對話和智慧體仍會搜尋),僅因預設值而處於關閉的開關也不會遷移:每項能力只要其插槽能解析就會執行。

工作區即持久化文件中所說的擁有者(owner):預設的 Docker Compose 部署(單使用者模式)只有一個工作區;多使用者部署中,每個已登入帳號各有一個工作區。

allowUserKeys

yaml
allowUserKeys: false

allowUserKeys(預設 true)決定使用者能否新增自己的供應商、Key 和 Token Plan。設為 false 時,使用者只能在 openmaic.yml 宣告的供應商中,為未鎖定的插槽選擇模型:不顯示 Token Plan 和新增服務入口,工作區先前新增的供應商也不再使用。請求自帶的模型、金鑰或端點(已棄用的 x-model、x-api-key、x-base-url 請求標頭以及媒體介面的供應商欄位)也會被忽略,供應商測試按鈕的請求標頭形式會被拒絕。由部署方承擔全部費用的多使用者部署通常會設定它,並鎖定需要固定的插槽。

金鑰與 OPENMAIC_SECRET_KEY

  • openmaic.yml 中的 key 透過 ${VAR} 來自環境變數,不會寫入資料庫,也不會傳送到瀏覽器。
  • 工作區在模型設定中填寫的 key 儲存在資料庫中,使用實例金鑰 OPENMAIC_SECRET_KEY 以 AES-256-GCM 加密。
  • 未設定 OPENMAIC_SECRET_KEY 時,伺服器端會在首次使用時於 data/instance-secret.key(Docker Compose 中位於 openmaic-data 資料卷內)產生一把隨機金鑰,之後持續使用它。

請把這把金鑰和資料庫備份放在一起:金鑰遺失或更換後,先前儲存的 key 無法讀取,需要重新填寫(模型設定會標示這些 key)。多個實例共用同一個資料庫時,或服務沒有持久化資料目錄時(例如未掛載資料卷的容器),請明確設定 OPENMAIC_SECRET_KEY;任何字串都可以,例如 openssl rand -base64 32 的輸出。

不設定它時,若資料目錄無法跨重新啟動保留(沒有掛載磁碟區的容器、暫存磁碟),每次啟動都會產生新金鑰;多個副本也會各自產生一把;工作目錄唯讀時則無法建立金鑰,在模型設定中儲存 key 會失敗。啟動時,若 OPENMAIC_SECRET_KEY 未設定且資料目錄不可寫入,或資料庫中已有 key 是以與目前不同的金鑰加密(例如 data/instance-secret.key 遺失),伺服器端會記錄警告,但仍會正常啟動。

bash
OPENMAIC_SECRET_KEY=...        # 模型設定中儲存的 key 所使用的實例金鑰
OPENMAIC_CONFIG=/etc/openmaic/openmaic.yml   # 選填:openmaic.yml 的其他路徑

工作區可以新增什麼

openmaic.yml 中的供應商可以是 schema 允許的任何內容。工作區在模型設定中新增的供應商限制較多,因為其端點和 key 由 Web 端的使用者填寫:

  • 不提供 Amazon Bedrock:它會使用伺服器端自己的 AWS 憑證簽署。
  • 不能設定 proxy,也不能使用金鑰對(credentials):AliDocMind 等使用金鑰對驗證的範本只能由部署設定。
  • 只有提供對話能力的範本才能設定自訂 baseUrl,而且帶自訂端點的供應商只能用於對話插槽。URL 不能包含憑證、查詢字串或片段,而且必須解析到公網位址,除非伺服器端設定了 ALLOW_LOCAL_NETWORKS=true。對它的請求不會跟隨重新導向。
  • 本機模型服務(Ollama、Lemonade)必須填寫自己的端點,不能使用範本預設的 localhost。
  • 自架的媒體、搜尋和文件服務(VoxCPM2、FunASR、Lemonade 語音與圖像、ComfyUI、SearXNG、自架 MinerU)執行在伺服器端自己的網路中,只能由部署設定。
  • 維運人員用舊版 <CAP>_<VENDOR>_ENABLED=false 變數關閉的供應商不會提供。
  • 工作區不能宣告 openmaic.yml 已宣告過的供應商 ID。

如果希望使用者存取內網服務,請在 openmaic.yml 中宣告這些供應商。

API 呼叫端與已棄用的請求欄位

生成請求不再需要攜帶 key 或模型選擇:/api/generate-classroom 等無瀏覽器 API 呼叫會依呼叫端所在的工作區,透過與 Web 端相同的插槽解析模型。

舊的請求標頭 x-model、x-api-key、x-base-url、x-provider-type、x-model-routes、x-image-provider、x-image-model、x-video-provider、x-video-model,以及語音、搜尋和文件介面中對應的請求本文欄位,皆已棄用。只有當它們會影響的插槽沒有指派,或者在仍透過舊變數設定的伺服器上只有由舊變數轉換得到的預設值時(DEFAULT_MODEL 一直排在請求指定的模型之後),才會生效;openmaic.yml 中的預設值、工作區自己的選擇和鎖定的插槽優先,設為 null 的插槽無論請求寫了什麼都會拒絕。設定 allowUserKeys: false 時,它們一律會被忽略。請求第一次使用這些欄位時,伺服器端會記錄一則警告。它們將在上述模型設定發布兩個次要版本後移除。

從舊設定遷移

沒有 openmaic.yml 時,伺服器端會在啟動時轉換舊設定,記錄一則棄用提示,並為每個無法轉換的項目各記錄一則提示:

舊設定 轉換結果
供應商變數(OPENAI_API_KEY、TTS_MINIMAX_API_KEY 等)和 server-providers.yml 部署供應商(在模型設定中唯讀)。供應商 ID 就是範本 ID,與舊的 provider ID 相同,只有網路搜尋的 MiniMax(minimax-search)、網路搜尋的豆包(doubao-search)和 Lemonade 圖像(lemonade-image)例外
_MODELS 供應商的 models
DEFAULT_MODEL llm 的指派,作為工作區可以覆寫的預設值(不鎖定)。agent 插槽和以前一樣維持關閉
MODEL_FALLBACK DEFAULT_MODEL 的備援模型。沒有 DEFAULT_MODEL 時會被捨棄並給出提示
第一個設定的 TTS、ASR、圖像、影片和文件供應商,以及依舊優先順序選出的網路搜尋供應商 該能力的預設指派(圖像供應商由 DEFAULT_IMAGE_PROVIDER 指定)
MODEL_ROUTES 不轉換。 服務拒絕啟動(見下文)
<CAP>_<VENDOR>_ENABLED=false 仍然有效:無論誰指派,該供應商都維持關閉

存在 openmaic.yml 時,供應商變數、server-providers.yml、DEFAULT_MODEL、MODEL_FALLBACK 和 MODEL_ROUTES 都不再參與模型解析,並會給出提示。<CAP>_<VENDOR>_ENABLED=false 仍然有效;要關閉某項能力,請改為把它的插槽設為 null。

環境變數。 遷移前:

bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=openai:gpt-5.5
MODEL_FALLBACK=anthropic:claude-sonnet-4-6
TTS_MINIMAX_API_KEY=...
TAVILY_API_KEY=tvly-...

遷移後(key 仍留在環境變數中):

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}
  minimax-tts:
    preset: minimax-tts
    apiKey: ${TTS_MINIMAX_API_KEY}
  tavily:
    preset: tavily
    apiKey: ${TAVILY_API_KEY}

slots:
  llm:
    model: openai:gpt-5.5
    fallback: anthropic:claude-sonnet-4-6
  tts: minimax-tts
  webSearch: tavily

之後刪除 DEFAULT_MODEL 和 MODEL_FALLBACK。供應商變數可以保留,作為 ${VAR} 引用的來源。_BASE_URL 對應供應商的 baseUrl(OPENAI_BASE_URL → openai 下的 baseUrl;直接寫 URL,或在變數仍然設定時寫 ${OPENAI_BASE_URL}),_MODELS 對應它的 models 清單。與轉換得到的 DEFAULT_MODEL 一樣,這裡寫出的 llm 是工作區可以修改的伺服器端預設值;如需對所有人固定,請加上 lock: [llm]。與轉換結果不同,agent 現在會沿用 llm:如需維持 Pro 智慧體關閉,請寫 agent: null。

server-providers.yml。 遷移前:

yaml title="server-providers.yml"
providers:
  azure:
    apiKey: ...
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
pdf:
  alidocmind:
    accessKeyId: ...
    accessKeySecret: ...
web-search:
  searxng:
    baseUrl: http://searxng:8080

遷移後:

yaml title="openmaic.yml"
providers:
  azure:
    preset: azure
    apiKey: ${AZURE_OPENAI_API_KEY}
    baseUrl: https://YOUR-RESOURCE.openai.azure.com/openai
    models: [my-deployment]
  alidocmind:
    preset: alidocmind
    credentials:
      accessKeyId: ${ALIDOCMIND_ACCESS_KEY_ID}
      accessKeySecret: ${ALIDOCMIND_ACCESS_KEY_SECRET}
  searxng:
    preset: searxng
    baseUrl: http://searxng:8080

slots:
  llm: azure:my-deployment
  document: alidocmind
  webSearch: searxng

server-providers.yml 的每個區段都對應使用相應範本的供應商(providers → 對話範本,tts、asr、pdf → 文件,image、video、web-search),accessKeyId/accessKeySecret 移到 credentials 之下。

MODEL_ROUTES。 設定了 MODEL_ROUTES 但沒有 openmaic.yml 的服務不會啟動:

text
MODEL_ROUTES does not carry over to the model configuration: write the per-stage models as slots in openmaic.yml (or the file named by OPENMAIC_CONFIG) and remove MODEL_ROUTES

依階段的路由與插槽並非一一對應,因此不會自動轉換。請把每條路由改寫成該階段所屬的插槽:

MODEL_ROUTES 階段 插槽
web-search-query-rewrite course.research
scene-outlines-stream course.outline
agent-profiles course.agents
scene-content course.content
scene-content:slide、:quiz、:interactive、:pbl course.content.slide、.quiz、.interactive、.pbl
scene-actions course.actions
chat-adapter、quiz-grade、pbl-v2-runtime 及其 :instructor、:open-task、:evaluate、:simulator 變體 classroom
generate-classroom llm
maic-agent-driver agent
conversation-title agent.title
pbl-chat、maic-agent 無:這些階段已移除,直接刪掉

多個階段共用一個插槽時,為該插槽指定它們應共同使用的那個模型。路由中的 thinking 移到插槽的 thinking,maic-agent-driver 路由的 api 和 contextWindow 移到 agent 插槽。遷移前:

bash
DEFAULT_MODEL=openai:gpt-5.5
MODEL_ROUTES='{"scene-outlines-stream":"anthropic:claude-sonnet-4-6","scene-content:slide":{"model":"openai:gpt-5.4","thinking":{"effort":"low"}},"maic-agent-driver":{"model":"openai:gpt-5.5","api":"openai-completions"}}'

遷移後:

yaml title="openmaic.yml"
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
  anthropic:
    preset: anthropic
    apiKey: ${ANTHROPIC_API_KEY}

slots:
  llm: openai:gpt-5.5
  course.outline: anthropic:claude-sonnet-4-6
  course.content.slide:
    model: openai:gpt-5.4
    thinking: { effort: low }
  agent:
    model: openai:gpt-5.5
    api: openai-completions

一旦存在 openmaic.yml,供應商變數本身就不再設定任何東西:只靠變數設定的 TTS、圖像、文件或網路搜尋供應商(TTS_MINIMAX_API_KEY、IMAGE_SEEDREAM_API_KEY、PDF_MINERU_BASE_URL、TAVILY_API_KEY 等)不再被使用,除 webFetch 外,其他能力也不會得到預設供應商。請把用到的每個供應商寫進 providers,並在 slots 中指定 tts、image、document、webSearch(以及 asr、video),做法同上面的環境變數範例;未指定的其他槽位保持未設定,除非某個工作區在模型設定中為它指定。

然後刪除 MODEL_ROUTES、DEFAULT_MODEL 和 MODEL_FALLBACK。

Docker。 把檔案掛載到應用程式旁邊,key 仍放在 .env.local 中:

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

ACCESS_CODE —— 全站存取密碼

對於共享部署(內部 demo、課堂),可以設定存取碼,訪客必須先輸入密碼才能看到應用程式:

bash
ACCESS_CODE=your-secret-code

請使用足夠長的隨機值(至少 16 個字元),因為該密碼是保護部署的唯一金鑰。驗證通過後會在 HTTP-only cookie 中儲存一個簽名權杖,有效期 7 天,由伺服器端強制檢查,過期後需要重新驗證。只有在受信任的反向代理後設定 TRUST_PROXY_HEADERS=true(由代理覆寫 x-forwarded-for / x-real-ip)時才會限制驗證速率:依用戶端限制(每個用戶端 60 秒內 10 次),驗證成功會清除該用戶端的計數;沒有可信代理時應用程式無法將請求歸因到特定用戶端,因此完全不限流,保護取決於密碼的長度與隨機性。留空即可關閉此功能。

功能開關

功能開關的值可設為 true 或 1;除非另有說明,其他值都視為關閉。NEXT_PUBLIC_* 開關會在建置時編譯至用戶端,修改後需要重新建置:

bash
# MAIC Editor(Pro 模式)
NEXT_PUBLIC_MAIC_EDITOR_ENABLED=true

# Pi 播放態中的 PPT 與互動頁課件引用(建置時開關;不影響編輯態引用)
NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED=true

# 職業教育任務引擎(伺服器端開關)
OPENMAIC_ENABLE_VOCATIONAL=true

# 顯示職業教育實驗開關
NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI=true

# 顯示影片匯出入口
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true

# 顯示實驗性 PPTX 匯入入口
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true

課堂對話固定由 Pi 執行,需要支援工具/函式呼叫的模型:在課程模型配置中為課堂互動選擇這樣的模型,或在 openmaic.yml 中設定 classroom 槽位(或它繼承的 llm 槽位)。Gemini 請使用原生 google 預設,不要使用 Google 的 OpenAI 相容端點。 內建模型目錄標明不支援工具呼叫的模型會在推理前被拒絕,回傳 400 MODEL_CONFIG_INVALID(reason: tool_calling_required),課程模型配置也會在課堂互動插槽上註明這項要求。

舊版 director-graph 課堂對話已移除,NEXT_PUBLIC_PI_CHAT_ENABLED 不再選擇對話鏈路。請從設定中刪除此變數:production 建置只接受未設定、空字串、true 或 1(精確比對),其他任何值都會建置失敗並提示遷移方式。使用 Docker Compose 時,服務的 env_file(.env.local)提供的是執行時值,不會進入建置參數。

PPTX 匯入目前仍是實驗性入口,解析結果尚未完整整合至課堂資料流。以上 NEXT_PUBLIC_* 變數都是建置時開關;Docker 部署還需要透過 build args 傳入,不能只寫在容器執行時環境變數中。

其他伺服器端選項

以下伺服器端選項也可以透過環境變數設定:

bash
# 場景內容平行生成;0 或未設定表示依序生成
PARALLEL_SCENE_CONCURRENCY=3

# 允許存取 localhost、內部網路等本機網路位址;僅用於自託管/內部網路部署
ALLOW_LOCAL_NETWORKS=true

# 可選的 MP4 渲染服務
RENDER_SERVICE_URL=http://render-service:9000

# 日誌和推理
LOG_LEVEL=info
LOG_FORMAT=pretty
LLM_THINKING_DISABLED=false

舊版供應商環境變數

這些變數和 server-providers.yml 是早期版本的設定方式。在沒有 openmaic.yml 時它們仍然有效(見從舊設定遷移),但已棄用,將在模型設定發布兩個次要版本後移除。使用 openmaic.yml 時,它們只作為 ${VAR} 引用的來源。DEFAULT_MODEL 和 MODEL_FALLBACK 請見遷移一節;MODEL_ROUTES 已不再讀取。

LLM 供應商

雲端供應商通常使用三個環境變數:API key、base URL 和 model 清單。API key 通常為必填,base URL 和 model 清單則為選填;Azure OpenAI 需要設定資源 endpoint,Ollama 和 Lemonade 不需要 API key。

bash
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=                # 選填的 base URL 覆寫值
OPENAI_MODELS=                  # 選填的模型白名單(以逗號分隔)

支援的 provider 字首:

字首 供應商
OPENAI_ OpenAI
AZURE_OPENAI_ Azure OpenAI
ANTHROPIC_ Anthropic
GOOGLE_ Google Gemini
DEEPSEEK_ DeepSeek
QWEN_ Alibaba Qwen
KIMI_ Moonshot Kimi
MINIMAX_ MiniMax(預設用 Anthropic 相容端點)
GLM_ Zhipu GLM
SILICONFLOW_ SiliconFlow
DOUBAO_ Doubao(ByteDance)
OPENROUTER_ OpenRouter
GROK_ xAI Grok
TENCENT_ Tencent Hunyuan
TENCENT_HUNYUAN_ Tencent Hunyuan(別名)
XIAOMI_ Xiaomi MiMo
MIMO_ Xiaomi MiMo(別名)
OLLAMA_ Ollama(本機)
LEMONADE_ Lemonade(本機)

Azure OpenAI 使用 deployment name 作為模型 ID:

bash
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_BASE_URL=https://YOUR-RESOURCE.openai.azure.com/openai
AZURE_OPENAI_MODELS=your-deployment-name

如需連接其他 OpenAI 相容的 LLM 服務,請在 openmaic.yml 中宣告一個帶 baseUrl 和 models 的 openai-compatible 供應商,或在 設定 → 模型服務 → 語言模型 中用 新增供應商 新增。

Gemini 課堂互動請使用原生 google 預設:在設定 → 模型服務中新增 Google,並在課程模型配置中為課堂互動選擇該模型。Pi 會提前拒絕 Google 官方 OpenAI 相容端點(https://generativelanguage.googleapis.com/v1beta/openai)並提示遷移;相容路線的工具呼叫支援由 #1556 追蹤。此檢查僅識別該官方端點,無法識別任意第三方閘道背後的上游。

本機模型(Ollama 和 Lemonade)

Ollama 和 Lemonade 不需要 API key;本機服務的 base URL 應寫在伺服器端設定中,以通過 SSRF 驗證:

bash
OLLAMA_BASE_URL=http://localhost:11434/v1
# LEMONADE_BASE_URL=http://localhost:13305/v1

如需限制可用模型,可使用 OLLAMA_MODELS 或 LEMONADE_MODELS 指定模型白名單。

TTS 供應商

伺服器端 TTS 供應商使用 TTS__API_KEY,並可選擇設定 TTS__BASE_URL。

bash
# Doubao TTS(Volcengine Seed-TTS,原生 MP3)
TTS_DOUBAO_API_KEY=appId:accessKey
TTS_DOUBAO_BASE_URL=       # 選填覆寫值

# Qwen TTS
TTS_QWEN_API_KEY=
TTS_QWEN_BASE_URL=         # 選填覆寫值

# 任何 OpenAI 相容的 TTS 端點
TTS_OPENAI_API_KEY=
TTS_OPENAI_BASE_URL=

# VoxCPM2(自託管 TTS,支援聲音複製,詳見 VoxCPM2 獨立頁面)
TTS_VOXCPM_BASE_URL=http://localhost:8000

支援的 TTS 字首包括 TTS_OPENAI_、TTS_AZURE_、TTS_GLM_、TTS_QWEN_、TTS_MINIMAX_、TTS_DOUBAO_、TTS_ELEVENLABS_、TTS_GOOGLE_、TTS_VOXCPM_ 和 TTS_LEMONADE_。本機 Lemonade TTS 和 VoxCPM2 不需要 API key。瀏覽器原生 TTS 不需要伺服器端設定。

管理員可以使用 TTS__ENABLED=false 在伺服器端強制關閉某個 TTS 供應商。VoxCPM2(自託管 TTS + 聲音複製)請見獨立的 VoxCPM2 章節。

如需使用其他 OpenAI 相容的 TTS 端點,請在 openmaic.yml 中宣告一個帶該 baseUrl 的 openai-tts 供應商,並把 tts 插槽指派給它。工作區不能在模型設定中新增自訂 TTS 端點。

ASR(語音轉文字)

bash
# OpenAI Whisper
ASR_OPENAI_API_KEY=
ASR_OPENAI_BASE_URL=       # 選填覆寫值

# Qwen ASR
ASR_QWEN_API_KEY=
ASR_QWEN_BASE_URL=         # 選填覆寫值

# Azure ASR
ASR_AZURE_API_KEY=
ASR_AZURE_BASE_URL=https://{region}.api.cognitive.microsoft.com

# FunASR(本機,不需要 key)
ASR_FUNASR_BASE_URL=http://localhost:8000/v1

# Lemonade ASR(本機,不需要 key)
ASR_LEMONADE_BASE_URL=http://localhost:13305/v1

使用 funasr-server --device cuda 啟動 OpenAI 相容服務。它在 /v1/audio/transcriptions 接收 WAV,並可在模型選擇器中使用 SenseVoiceSmall、Paraformer 和 Fun-ASR-Nano。

瀏覽器原生 ASR 不需要伺服器端設定。

如需使用其他 OpenAI 相容的 ASR 端點,請在 openmaic.yml 中以 openai-whisper 範本和它的 baseUrl 宣告該服務,並把 asr 插槽指派給它。工作區不能在模型設定中新增自訂 ASR 端點:

yaml title="openmaic.yml"
providers:
  my-asr:
    preset: openai-whisper
    apiKey: ${MY_ASR_API_KEY}
    baseUrl: https://asr.example.com/v1

slots:
  asr: my-asr:whisper-1

圖像生成供應商

圖像生成供應商使用 IMAGE__API_KEY,可選 IMAGE__BASE_URL。支援的字首包括:

IMAGE_OPENAI_、IMAGE_SEEDREAM_、IMAGE_QWEN_IMAGE_、IMAGE_NANO_BANANA_、IMAGE_MINIMAX_、IMAGE_GROK_、IMAGE_GLM_ 和 IMAGE_LEMONADE_。

GLM 圖像(智譜 CogView)使用 CogView 模型(cogview-3-flash、cogview-4)產生圖片:

yaml title="openmaic.yml"
providers:
  glm-image-service:
    preset: glm-image
    apiKey: ${IMAGE_GLM_API_KEY}
    # 可選:
    baseUrl: https://open.bigmodel.cn/api/paas/v4

slots:
  image: glm-image-service:cogview-3-flash

也可以透過環境變數設定:

bash
IMAGE_GLM_API_KEY=
# 可選:
IMAGE_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

Lemonade 是本機服務,不需要 key:

bash
IMAGE_LEMONADE_BASE_URL=http://localhost:13305/v1

ComfyUI Image 不需要 API key。它執行在你自己的網路中,因此由部署設定:在 openmaic.yml 中用 ComfyUI 服務的位址宣告它,並把 image 插槽指派給它。

yaml title="openmaic.yml"
providers:
  comfyui:
    preset: comfyui-image
    baseUrl: http://host.docker.internal:8188

slots:
  image: comfyui

把以 API 格式匯出的工作流程 JSON 放到 OpenMAIC 的 public/ 目錄中,檔名需符合 comfyui-*.json 或包含 workflow;OpenMAIC 會自動偵測這些檔案並作為工作流程提供。使用 Docker 時,請在建置映像檔前加入工作流程,或把單一工作流程檔案掛載到容器內的 /app/public/。由於該供應商由部署宣告,執行在主機或內網中的 ComfyUI 不需要 ALLOW_LOCAL_NETWORKS。

影片生成供應商

影片生成供應商使用 VIDEO__API_KEY,可選 VIDEO__BASE_URL。支援的字首包括:

VIDEO_SEEDANCE_、VIDEO_KLING_、VIDEO_SORA_、VIDEO_MINIMAX_、VIDEO_GROK_ 和 VIDEO_HAPPYHORSE_。

文件和媒體解析

課程材料的實際格式取決於所選解析器。目前支援文字、PDF、Office 文件、圖片以及部分音訊與影片格式;不同 provider 支援的格式和功能各不相同。

bash
# MinerU 自託管
PDF_MINERU_BASE_URL=http://localhost:8888

# MinerU 自託管的可選後端
PDF_MINERU_BACKEND=pipeline

# MinerU Cloud
PDF_MINERU_CLOUD_API_KEY=
PDF_MINERU_CLOUD_BASE_URL=https://mineru.net/api/v4

# AliDocMind(使用 Alibaba Cloud AccessKey,而不是獨立的 API key)
ALIDOCMIND_ACCESS_KEY_ID=
ALIDOCMIND_ACCESS_KEY_SECRET=
ALIDOCMIND_BASE_URL=       # 選填覆寫值

unpdf 內建於 OpenMAIC,可用於基礎 PDF 解析。Office 文件、圖片和需要 OCR、表格、公式或版面分析的材料,應選擇相容的 MinerU 或 AliDocMind provider。

AliDocMind 的文件解析支援 PDF、DOCX、PPTX、XLSX,以及 PNG、JPG/JPEG、BMP、GIF。目前音訊與影片材料僅支援透過 AliDocMind 解析:影片格式為 MP4、MOV、AVI、MKV、WMV,音訊格式為 MP3、WAV、AAC;不支援 M4A。

聯網搜尋

設定 Tavily、Exa、Firecrawl、Bocha、Brave、Baidu、SearXNG 或 MiniMax:

bash
TAVILY_API_KEY=
TAVILY_BASE_URL=           # 選填覆寫值

EXA_API_KEY=
EXA_BASE_URL=https://api.exa.ai  # 選填覆寫值

FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev  # 選填覆寫值

BOCHA_API_KEY=
BOCHA_BASE_URL=            # 選填覆寫值

BAIDU_API_KEY=
BAIDU_BASE_URL=https://qianfan.baidubce.com  # 選填覆寫值

# 自託管 SearXNG,不需要 API key
SEARXNG_BASE_URL=

WEB_SEARCH_MINIMAX_API_KEY=
WEB_SEARCH_MINIMAX_BASE_URL=https://api.minimaxi.com  # 選填覆寫值

Brave 和 SearXNG 不需要 API key。網路搜尋沒有逐次生成的開關:只要工作區的 webSearch 插槽能解析就會執行;關閉它(在 openmaic.yml 中寫 webSearch: null,或在 設定 → 課程模型設定 中關閉搜尋開關)會影響該工作區之後的生成。Grok 的聯網搜尋透過 Grok LLM 的搜尋工具提供,不是獨立的聯網搜尋 provider。

server-providers.yml

已由 openmaic.yml 取代。兩者同時存在時,只有 openmaic.yml 用於設定模型。

除了環境變數,也可以將程式碼中已註冊 provider 的設定放在專案根目錄下的 server-providers.yml 檔案中。伺服器啟動時會載入此檔案,其結構與環境變數分類相對應:

yaml title="server-providers.yml"
providers:
  openai:
    apiKey: sk-...
    baseUrl: https://api.openai.com/v1
    models:
      - gpt-5.5
  anthropic:
    apiKey: sk-ant-...

tts:
  doubao-tts:
    apiKey: appId:accessKey

asr:
  openai-whisper:
    apiKey: sk-...

pdf:
  mineru:
    baseUrl: http://localhost:8888
  alidocmind:
    accessKeyId: your-access-key-id
    accessKeySecret: your-access-key-secret

image:
  seedream:
    apiKey: ...

video:
  seedance:
    apiKey: ...

web-search:
  tavily:
    apiKey: tvly-...
  exa:
    apiKey: ...
  firecrawl:
    apiKey: fc-...
  searxng:
    baseUrl: http://localhost:8080

環境變數會逐欄位覆蓋 YAML 中的同名 provider 設定。鍵名必須使用程式碼中已註冊的 provider ID,例如 openai、doubao-tts、openai-whisper、mineru、alidocmind、seedream、seedance、exa、tavily 或 searxng;未知 ID 不能在這裡註冊為自訂 LLM provider。

伺服器端託管供應商的模型授權

對於使用部署憑證把所選模型送往上游的供應商,請在 openmaic.yml 中設定明確的 models 清單(providers..models),或設定對應的 *_MODELS 環境變數 / server-providers.yml 中該區段的 models 清單。這適用於對話、圖像、影片、語音合成、語音辨識和基於模型的網路搜尋。模型 ID 在載入設定時去掉首尾空白後精確比對,區分大小寫。確定性的轉接器別名依宣告的模型 ID 授權。請求未列出的模型會回傳 HTTP 403,錯誤碼 PROVIDER_DISABLED,訊息為「This model is not authorized for server-managed credentials」。

破壞性變更: 沒有明確清單時,只能使用部署的預設/備援模型,以及該能力的插槽所引用的主要/備援模型。在舊版設定下,DEFAULT_MODEL 和 MODEL_FALLBACK 授權對話模型。只寫供應商的媒體插槽只授權該轉接器的預設模型,不授權該預設的整個模型目錄。工作區的指派無法擴大部署的授權範圍。音色註冊和合成也必須授權為音色相容性而選擇的模型。

遷移時,列出使用者應能選擇的每個模型,重新啟動服務,並更新不再獲授權的工作區選擇。對於有憑證但沒有清單的供應商,啟動日誌會指出需要新增的設定。使用者自己的供應商憑證,以及 Ollama、Lemonade 這類不需要 key 的本機供應商不受影響。未設定憑證的供應商不受限制,因此不要把它指向會自行加上上游憑證的閘道(例如內部的 OpenAI 相容代理):請給該閘道一個 key,把它設定為帶 models 清單的有憑證供應商,或在閘道本身限制模型。沒有模型選擇的文件解析器和搜尋服務(如 Tavily)不需要模型清單。

yaml
providers:
  openai:
    preset: openai
    apiKey: ${OPENAI_API_KEY}
    models: [gpt-5.4-mini]
slots:
  llm: openai:gpt-5.4-mini

舊版等價寫法:OPENAI_API_KEY=... 和 OPENAI_MODELS=gpt-5.4-mini。媒體能力使用能力字首,例如 IMAGE_SEEDREAM_MODELS、VIDEO_SEEDANCE_MODELS、TTS_OPENAI_MODELS、ASR_OPENAI_MODELS 和 WEB_SEARCH_CLAUDE_MODELS。

DeepSeek 網路搜尋與網頁擷取

deepseek 預設在對話、網路搜尋和託管 Markdown 擷取之間共用 DEEPSEEK_API_KEY 與 API 基底位址。搜尋使用 POST /v1/tools/search,最多傳回 50 筆結果,查詢依 Unicode code point 截斷至 128 個字元,優先使用 summary,空白時改用 snippet。擷取使用 POST /v1/tools/fetch,讀取 content 與非空的選填 title。

舊版設定需使用 WEB_SEARCH_DEEPSEEK_ENABLED=true,或在 server-providers.yml 明確加入 web-search.deepseek。僅設定對話憑證不會啟用搜尋或取代現有預設搜尋。WEB_SEARCH_DEEPSEEK_ENABLED=false 會強制停用搜尋。有 openmaic.yml 時,舊版變數無法指定插槽,必須明確宣告提供者並指定 webSearch 和/或 webFetch。

The default API base is https://api.deepseek.com/v1. On api.deepseek.com, every base path (including /beta) is normalized to /v1 before appending /tools/search or /tools/fetch. Custom gateway bases preserve their configured path prefix; a bare root gets /v1 first. Custom endpoints belong in deployment configuration, while client and workspace overrides remain restricted to official endpoints. The default fetch reader remains builtin. See DeepSeek official pricing for current request charges. Failed requests and searches with no results are not billed.

yaml
providers:
  deepseek:
    preset: deepseek
    apiKey: ${DEEPSEEK_API_KEY}
    baseUrl: https://api.deepseek.com/v1
slots:
  webSearch: deepseek
  webFetch: deepseek

Kimi 對話與網路搜尋

宣告一個 Kimi 帳號,並將兩個插槽都指派給它:

yaml
providers:
  kimi:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    # 可選:國際版開放平台使用 https://api.moonshot.ai/v1。
    # baseUrl: https://api.moonshot.ai/v1
slots:
  llm: kimi:kimi-k3
  webSearch: kimi

使用 Coding Plan 金鑰時,請設定 preset: kimi-coding-plan 和 llm: kimi:kimi-for-coding;其預設位址為 https://api.kimi.com/coding/v1。開放平台和 Coding Plan 金鑰必須使用各自對應的基礎 URL。 在舊版設定中,只有明確啟用後,KIMI_API_KEY 和可選的 KIMI_BASE_URL 才會為搜尋提供憑證:設定 WEB_SEARCH_KIMI_ENABLED=true,或在 server-providers.yml 中新增 web-search.kimi(例如設定 enabled: true)。僅有對話憑證不會啟用搜尋,也不會改變預設搜尋供應商。即使插槽已指派給 Kimi,WEB_SEARCH_KIMI_ENABLED=false 仍會停用 Kimi 搜尋。WEB_SEARCH_KIMI_ENABLED 是 Kimi 搜尋的唯一開關。 KIMI_MODELS 僅適用於對話。舊版對話和搜尋帳號衝突時會產生警告,並略過搜尋項目;若需使用不同帳號,請在 openmaic.yml 中宣告不同的供應商 ID。 存在 openmaic.yml 時,請明確宣告供應商並指派插槽。設定和首次使用精靈允許透過同一帳號使用兩項能力。

端點選擇取決於基礎路徑:移除結尾斜線後,以 /coding/v1 結尾的路徑使用 /search,其他路徑使用 /tools/search_pro。部署設定的自訂代理必須保留此路徑配置。精靈只為官方帳號位址推薦搜尋,因此工作區自訂對話代理不會自動取得搜尋指派。

存在 openmaic.yml 時,請宣告 providers.: { preset: kimi } 並指派 slots.webSearch: ;舊版變數 WEB_SEARCH_KIMI_ENABLED 無法指派該插槽。

網頁擷取服務插槽

webFetch 決定智慧體的 fetch_url 工具使用哪個讀取服務。在舊版設定、空設定和 YAML 設定中,預設使用 builtin;即使 lock: all 下沒有指定該根插槽也是如此。明確設定 webFetch: null 會關閉擷取,並從後續執行中移除該工具及其提示。每次請求只使用一個供應商,不會備援或升級至其他服務。無效的供應商參照或被政策拒絕的工作區端點會在智慧體啟動前的能力解析階段導致整個智慧體執行失敗,與 webSearch 一致。這屬於設定錯誤,並非 fetch_url 傳回的失敗結果,也不會自動改用內建讀取服務。

供應商 憑證 內容擷取
builtin 無需憑證 本機 Readability/Turndown 讀取服務;PDF 使用 document 插槽
kimi 既有 Kimi 供應商的金鑰和基底網址 託管 Markdown 擷取服務,也接收 PDF 網址
deepseek 既有 DeepSeek 對話金鑰與 API 基底位址 託管 Markdown 擷取
firecrawl Firecrawl API 金鑰(preset: firecrawl) 託管 Markdown 擷取服務,回傳頁面主要內容,也接收 PDF 網址
yaml
providers:
  reader:
    preset: kimi
    apiKey: ${KIMI_API_KEY}
    baseUrl: https://api.moonshot.cn/v1
slots:
  webFetch: reader

kimi 和 kimi-coding-plan 預設都提供 webFetch。使用 Coding Plan 憑證時,請指定 preset: kimi-coding-plan,其預設基底網址為 https://api.kimi.com/coding/v1;明確設定的基底網址會保留。Moonshot 開放平台網址(https://api.moonshot.cn/v1 和 https://api.moonshot.ai/v1)使用 /tools/fetch,Coding Plan 使用 /fetch。兩者都傳送帶有 { "url": ... } 的 POST 請求,並使用 Bearer 驗證。介接器接受 markdown 或 content。維護者使用真實金鑰測試了 POST https://api.kimi.com/coding/v1/fetch,確認回應包含 { markdown, title, url };國際端點的可用性仍未驗證。請求遵循供應商的 proxy 設定及標準代理環境變數。

DeepSeek 也透過 deepseek 預設提供託管 Markdown 擷取,共用對話金鑰與 API 基底位址。依上述範例明確指定 webFetch,呼叫 /v1/tools/fetch,並保留部署中的閘道位址。逾時與僅對傳輸錯誤重試一次的行為與 Kimi 相同,不重試 HTTP 錯誤或無效回應。

在 設定 → 模型服務 → 網頁擷取 中使用既有供應商憑證。此金鑰與同一供應商的對話服務共用,修改或移除金鑰也會影響對話。在 課程模型設定 → 網頁擷取 中選擇讀取服務。管理員鎖定後,卡片為唯讀。首次設定會保留內建預設值,直到明確選擇其他讀取服務。工作區憑證可使用官方 API 基底網址;自訂擷取網址與代理必須由管理員在 openmaic.yml 中宣告。

與 kimi 不同,firecrawl 使用獨立的金鑰:在 openmaic.yml 中宣告 preset: firecrawl 的供應商並設定 apiKey(例如 ${FIRECRAWL_API_KEY}),或在 設定 → 模型服務 → 網頁擷取 中填寫金鑰。它以 Bearer 驗證傳送 POST /v2/scrape,以 Markdown 回傳頁面主要內容。預設基底網址為 https://api.firecrawl.dev;自行託管 Firecrawl 的基底網址須由管理員在 openmaic.yml 中宣告。

同一個 firecrawl 供應商也提供網路搜尋(POST /v2/search):一把金鑰和一個基底網址同時用於兩者,因此可以把同一個供應商同時指定給 slots.webSearch 和 slots.webFetch。舊版的 FIRECRAWL_API_KEY 和 FIRECRAWL_BASE_URL 變數設定的就是這個供應商;在把 webFetch 指定給它之前,網頁擷取仍使用 builtin。

網址轉送至託管服務之前,必須通過工作階段網址授權、嚴格網址正規化及全部本機 DNS 位址檢查。接收內容前,也會對服務回傳的最終網址執行相同檢查。託管服務控制其自身的 DNS 和中間重新導向,本機檢查無法固定其網路連線。回傳的文字仍是不可信資料,經過驗證頁面偵測、位元組限制和截斷標記處理,並沿用網頁素材持久化及 {trusted, untrusted} 結果結構。


Packages Docs Content Docs Deployment.Ar (`packages/docs/content/docs/deployment.ar.mdx`)


title: النشر

OpenMAIC تطبيق Next.js يحتاج إلى عملية Node.js تعمل باستمرار وإلى PostgreSQL. يجري توليد المقررات على الخادم: يُحفظ كل توليد في قاعدة البيانات وينفّذه عامل داخل عملية الخادم، فيستمر بعد مغادرة المتصفح للصفحة، وإذا توقفت العملية تتسلّمه عملية أخرى (أو العملية نفسها بعد إعادة التشغيل) من آخر خطوة مكتملة. هناك طريقتان مدعومتان للنشر:

  • Docker Compose (موصى به): التطبيق وPostgreSQL في حاويتين.
  • صورة Docker أو pnpm start مع قاعدة PostgreSQL تديرها بنفسك.

بدءًا من 1.2.0 لم تعد المنصات بلا خادم (serverless) مثل Vercel مدعومة: الدالة التي تنتهي بانتهاء الطلب لا يمكنها تنفيذ توليد. راجع قسم «Vercel (حتى 1.1.x)» أدناه.

Docker

يحتوي المستودع على Dockerfile مناسب للإنتاج. يستخدم داخل الصورة Node.js 22، قم بالبناء والتشغيل:

bash
docker build -t openmaic .

docker run --env-file .env.local -e DATABASE_URL=postgres://user:password@db-host:5432/openmaic -v openmaic-data:/app/data -p 3000:3000 openmaic

يوصى باستخدام تكوين Docker Compose الموجود في المستودع:

bash
cp .env.example .env.local
# عدّل .env.local (المفاتيح وخيارات الخادم)، ثم:
docker compose up --build

يبدأ نشر Compose الافتراضي حاويتين: تطبيق OpenMAIC وPostgreSQL، ويضبط DATABASE_URL بينهما، ويربط مجلدي البيانات openmaic-data وopenmaic-postgres. يمكن تكوين مزودين ووظائف أخرى حسب الحاجة، راجع شرح الإعدادات.

تُضبط النماذج بإحدى طريقتين. في التثبيت الشخصي، شغّل الحزمة وافتح التطبيق واربط خدمة نماذج في إعدادات النماذج؛ تُحفظ الإعدادات في قاعدة البيانات. وفي النشر الذي تديره بالملفات، انسخ openmaic.example.yml إلى openmaic.yml، وأبقِ المفاتيح في .env.local، واربط الملف (انظر أدناه). راجع الإعدادات.

تنشئ الصورة مجلد البيانات في وحدة التخزين بملكية مستخدم التشغيل، لذا تحفظ وحدات التخزين الجديدة الدروس وسجلات الاستخدام مباشرة. قد تكون وحدة التخزين التي أنشأتها صورة أقدم مملوكة لـ root، ما يجعل كل حفظ على الخادم يفشل بـ EACCES بينما يواصل التطبيق العمل كالمعتاد (لا تصل الدروس إلى القرص دون أن يلاحظ أحد؛ ويظهر في السجل Failed to record usage ... EACCES: permission denied). أصلح وحدة التخزين هذه مرة واحدة، ثم أعد تشغيل التطبيق:

bash
docker exec -u root <container> chown -R 1001:1001 /app/data

تُحقن مفاتيح التكوين NEXT_PUBLIC_* أثناء بناء Docker، ولا يمكن الاعتماد فقط على .env.local وقت التشغيل. على سبيل المثال، لتمكين تصدير الفيديو واستيراد PPTX التجريبي:

bash
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true \
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true \
docker compose --profile video-export up --build

يمكن تمرير مفاتيح وظائف العميل الأخرى بنفس الطريقة. إذا استخدمت docker build، فاستخدم --build-arg المقابلة.

لاستخدام openmaic.yml اربطه في مجلد عمل التطبيق، أو في أي مكان آخر مع ضبط OPENMAIC_CONFIG على مساره:

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

لا تربط مسارًا غير موجود على المضيف: سينشئ Docker مجلدًا مكانه ويرفض الخادم البدء. مع docker run أضف -v "$PWD/openmaic.yml:/app/openmaic.yml:ro". أعد تشغيل الحاوية بعد تعديل الملف. الملف openmaic.yml مدرج في .dockerignore، فلا يُضمَّن أبدًا في صورة تُبنى من المستودع: اربطه وقت التشغيل بدلًا من ذلك. عند عدم وجود openmaic.yml ما زال server-providers.yml القديم يُقرأ من /app/server-providers.yml.

داخل حاوية Docker يشير localhost إلى الحاوية نفسها. إذا كان Ollama أو Lemonade أو VoxCPM أو ComfyUI يعمل على المضيف، فاستخدم host.docker.internal، مثل http://host.docker.internal:11434/v1 أو http://host.docker.internal:8188. وعادةً ما يتطلب Docker على Linux أيضًا إضافة extra_hosts: ["host.docker.internal:host-gateway"] إلى خدمة openmaic. المزودون المعلنون في openmaic.yml (أو عبر المتغيرات القديمة) يديرهم الخادم، لذا يمكن أن تكون عناوينهم على المضيف أو على شبكة خاصة دون ALLOW_LOCAL_NETWORKS. لا يهم هذا المتغير إلا للعناوين التي يكتبها المستخدم: عناوين URL الأساسية للمحادثة المضافة في الإعدادات ← خدمات النماذج، وحقول الطلب المهملة.

الاستضافة الذاتية على جهاز افتراضي

يحتاج المضيف إلى Node.js 22.19.0 أو أحدث، وpnpm 10.28.0. قم بالبناء والتشغيل باستخدام pnpm:

bash
pnpm install
pnpm build
DATABASE_URL=postgres://user:password@db-host:5432/openmaic pnpm start      # يستمع على المنفذ 3000 افتراضياً

شغّل التطبيق تحت مدير عمليات (systemd أو pm2 أو حاوية) يبقيه قيد التشغيل ويعيد تشغيله، وضع nginx أو Caddy أمام التطبيق لإنهاء TLS. تُحفظ المقررات ووسائطها المولّدة في PostgreSQL (أو S3)، لذا يمكن لعدة نسخ مشاركة قاعدة بيانات واحدة، بشرط أن تتشارك أيضًا مجلد البيانات والقيمة نفسها لـ OPENMAIC_SECRET_KEY (انظر أدناه).

يشغّل pnpm start الأمر next start، الذي يحذّر من أنه لا يعمل مع الإعداد output: standalone. هذا التحذير غير ضار: يقدّم next start البناء كاملًا من .next. أما صورة Docker فتشغّل الخادم المستقل (node server.js)، لكن هذا الخادم يغيّر مجلد العمل إلى مجلده الخاص، لذا استمر على الجهاز الافتراضي في استخدام pnpm start، الذي يقرأ data/ وopenmaic.yml من المجلد الذي يبدأ فيه.

الإعدادات المطلوبة

الإعداد Docker Compose صورة Docker أو pnpm start
DATABASE_URL مضبوط على PostgreSQL المرفقة مطلوب؛ بدونه يخرج الخادم عند البدء
وضع الهوية مستخدم واحد (OWNER_SINGLE_USER=true في docker-compose.defaults.env) إن لم يُضبط، يكون كل متصفح مالكًا مجهولًا مستقلًا
OPENMAIC_SECRET_KEY اختياري: يُحفظ السر المولَّد في وحدة التخزين openmaic-data مطلوب لعدة نسخ، أو عند عدم وجود مجلد data/ دائم
مجلد البيانات وحدة التخزين openmaic-data على /app/data مجلد data/ دائم في مجلد العمل، تتشاركه كل النسخ
النماذج openmaic.yml أو الإعدادات ← النماذج كذلك

وضع الهوية يحدد مالك المقرر. إن لم يُضبط، يكون كل متصفح مالكًا مجهولًا مستقلًا، ويرى متصفح آخر مكتبة فارغة. للتثبيت الشخصي اضبط OWNER_SINGLE_USER=true، ولفريق واحد يتشارك مكتبة واحدة اضبط PERSISTENCE_SHARED_OWNER_ID مع ACCESS_CODE، وللحسابات الحقيقية سجّل طرق مصادقة المالكين. راجع Owner identity و.env.example.

في الوضع المجهول يحمل ملف تعريف الارتباط الخاص بالمالك العلامة Secure في بيئة الإنتاج، وكذلك ملف تعريف الارتباط الخاص بـ ACCESS_CODE. لا تحفظ المتصفحات ملف تعريف ارتباط Secure إلا عبر HTTPS أو على http://localhost (ولا يحفظه Safari حتى هناك)، لذا يجب على النشر الذي يُقدَّم عبر HTTP العادي ضبط COOKIE_SECURE=0 (القيمة 0 تمامًا): وإلا يصبح كل طلب مالكًا جديدًا، وتفشل عمليات الكتابة بـ 403، ويُطلب رمز الوصول مرة أخرى. بدون TLS تنتقل ملفات تعريف الارتباط نصًا واضحًا، فلا تفعل ذلك إلا على شبكة موثوقة.

كذلك فإن HTTP العادي على أي عنوان غير localhost (عنوان IP في الشبكة المحلية، أو عنوان IP من Tailscale، أو اسم MagicDNS) ليس سياقًا آمنًا، لذا لا تتوفر في المتصفح واجهة Web Locks API. يواصل OpenMAIC الحفظ في هذه الحالة، لكن علامات التبويب الخاصة بالدورة نفسها لا تُنسَّق فيما بينها: إذا غيّرت علامتا تبويب المحادثة نفسها، فإن آخر حفظ هو الذي يُعتمد، وتبقى المحادثة المحذوفة في علامة تبويب محذوفةً حتى لو كانت علامة تبويب أخرى تعدّلها في الوقت نفسه. لاستخدام هاتف أو جهاز لوحي على شبكتك، يُفضَّل استخدام HTTPS، مثل تشغيل tailscale serve أمام الخادم.

يشفّر OPENMAIC_SECRET_KEY مفاتيح المزودين المحفوظة في إعدادات النماذج. بدونه تنشئ كل نسخة سرّها الخاص في data/instance-secret.key، فلا تستطيع النسخ التي تتشارك قاعدة البيانات قراءة المفاتيح التي حفظتها نسخة أخرى. احفظه مع نسخ قاعدة البيانات الاحتياطية؛ راجع الأسرار و OPENMAIC_SECRET_KEY.

تحتاج عدة نسخ تتشارك قاعدة بيانات أيضًا إلى مجلد بيانات دائم مشترك واحد (data/ في مجلد العمل، و/app/data في الصورة؛ مثل وحدة التخزين نفسها أو نظام ملفات شبكي على كل نسخة). تُحفظ مستندات المقررات ووسائطها المولّدة في PostgreSQL (أو في S3 مع ASSET_S3_BUCKET)، لكن كل عملية تحتفظ بهذه الملفات في مجلد بياناتها: ملفات المواد المرفوعة ونتائج استخراجها، والسرد ووسائط المواد للمقررات التي يحررها الوكيل (data/classrooms أو OPENMAIC_CLASSROOMS_DIR)، وسجلات الاستخدام (data/usage)، والسر المولَّد للنسخة. عملية التوليد التي تتسلّمها نسخة أخرى تقرأ المواد المرفوعة من هناك.

عمليات التوليد

تشغّل كل عملية خادم عاملًا للتوليد، ولا يعتمد ذلك على OPENMAIC_AGENT_RUNTIME_ENABLED. تضبطه هذه المتغيرات (أعداد صحيحة موجبة):

المتغير الافتراضي المعنى
OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER 2 عدد عمليات التوليد التي يمكن لمالك واحد أن يجريها في وقت واحد. لا تُحتسب عملية تنتظر تأكيد المخطط أو متوقفة عند خطوة فاشلة. عند تجاوز الحد يُرجع البدء أو تأكيد المخطط أو إعادة المحاولة 429 ACTIVE_RUN_LIMIT.
OPENMAIC_MAX_WAITING_RUNS_PER_OWNER 10 عدد عمليات التوليد التي يمكن أن تنتظر تأكيد المخطط لمالك واحد في وقت واحد.
OPENMAIC_GENERATION_RUN_MAX_CONCURRENT 4 عدد عمليات التوليد التي تنفّذها عملية خادم واحدة في وقت واحد.
OPENMAIC_GENERATION_RUN_STREAMS_PER_OWNER 16 عدد تدفقات أحداث التوليد التي يمكن لمالك واحد إبقاؤها مفتوحة على عملية واحدة.
OPENMAIC_GENERATION_RUN_RETENTION_HOURS 24 عدد الساعات التي تحتفظ فيها عملية توليد منتهية بسجل أحداثها الكامل قبل ضغطه.

تتشارك عمليات التوليد إعدادات التأجير مع بيئة تشغيل الوكيل (OPENMAIC_AGENT_RUNTIME_SCAN_INTERVAL_MS و_HEARTBEAT_MS و_LEASE_TTL_MS و_MAX_ATTEMPTS؛ راجع .env.example). عند إيقاف الخادم أو إعادة تشغيله، بما في ذلك التحديث المتدرّج، تتابع نسخة أخرى أو النسخة نفسها عند بدئها من جديد عمليات التوليد الجارية فيه من آخر خطوة مكتملة: يتوقف التقدّم نحو مدة التأجير (10 ثوانٍ افتراضيًا)، وتُولَّد الخطوة التي كانت قيد التنفيذ من جديد. وعملية التوليد التي تنقطع OPENMAIC_AGENT_RUNTIME_MAX_ATTEMPTS مرة متتالية (5 افتراضيًا) دون إكمال أي خطوة تتوقف مؤقتًا عند تلك الخطوة مع إمكانية إعادة المحاولة؛ زِد القيمة إذا كانت التحديثات المتدرّجة لنسخ كثيرة تبلغها.

التخزين الدائم على الخادم (PostgreSQL)

يحفظ OpenMAIC الدروس وسجل المحادثات وتقدم المتعلمين والوسائط المولّدة في PostgreSQL، لذلك DATABASE_URL إلزامي: بدونه يطبع الخادم [boot] Invalid server configuration; the server will not start: DATABASE_URL is not set. ... مع طريقة الإصلاح ويخرج برمز 1. واجهة برمجة التطبيقات للتخزين الدائم مضمنة في OpenMAIC ولا تحتاج إلى خدمة تخزين منفصلة.

يبدأ docker compose up قاعدة PostgreSQL بجانب التطبيق ويضبط له DATABASE_URL. للتطوير المحلي، يبدأ pnpm db:up قاعدة بيانات تطوير منفصلة من تعريف الخدمة نفسه (مشروع Compose ومجلد بيانات خاصان، openmaic-dev-db، مشتركة بين كل النسخ على هذا الجهاز، دون المساس بقاعدة بيانات الحزمة) على 127.0.0.1 (المنفذ OPENMAIC_DB_PORT، افتراضياً 5432)، ويوقفها pnpm db:down. ثم اضبط الاتصال في .env.local:

bash
DATABASE_URL=postgres://openmaic:[email protected]:5432/openmaic

لا توجد بيانات اعتماد منفصلة للتخزين الدائم: يُنسب كل طلب إلى المالك الذي تحدده آلية تحديد المالك (افتراضياً ملف تعريف ارتباط مجهول لكل متصفح)، وتُقسَّم جلسات وقت التشغيل والوسائط حسب هذا المالك. الهوية الافتراضية تعزل المتصفحات لكنها لا تصادق أحداً، لذا ينبغي للنشر الذي يخدم مستخدمين حقيقيين تسجيل طرق مصادقة للمالك مبنية على حساباته الخاصة. تمت إزالة PERSISTENCE_DEV_TOKEN وNEXT_PUBLIC_PERSISTENCE_TOKEN من الإصدارات السابقة ويتم تجاهلهما، ولم تعد جلسات وقت التشغيل المخزنة بمفتاح المتعلم القديم الذي أنشأه المتصفح قابلة للوصول. إذا كان PERSISTENCE_DEV_TOKEN هو بوابة الوصول الوحيدة لديك، فاحمِ النشر باستخدام ACCESS_CODE أو بوابة، أو سجّل طرق مصادقة للمالك، قبل الترقية.

يتم حفظ بيانات PostgreSQL في مجلد openmaic-postgres. يتم تعيين كلمة مرور PERSISTENCE_POSTGRES_PASSWORD فقط عند التهيئة الأولى لقاعدة البيانات، ولا يتم تعديلها تلقائياً عند تغيير متغير البيئة لاحقاً.

ما لم يُضبط OPENMAIC_SECRET_KEY، تحتوي وحدة التخزين openmaic-data أيضًا على instance-secret.key، وهو سر المثيل المولَّد الذي يشفّر مفاتيح المزودين المحفوظة في إعدادات النماذج. انسخه احتياطيًا مع قاعدة البيانات: بدونه يجب إدخال المفاتيح المحفوظة من جديد. عند تشغيل عدة نسخ، أو من دون وحدة تخزين دائمة، أو على نظام ملفات للقراءة فقط، اضبط OPENMAIC_SECRET_KEY بدلًا من ذلك (القيمة نفسها في كل النسخ)؛ راجع الأسرار و OPENMAIC_SECRET_KEY.

كانت الإصدارات السابقة قادرة على حفظ الدروس في المتصفح (IndexedDB). أُزيل هذا الوضع، ويتم تجاهل متغير البناء NEXT_PUBLIC_PERSISTENCE. تنتقل الدروس المحفوظة بهذه الطريقة إلى الخادم تلقائيًا عندما يفتح ذلك المتصفح التطبيق المُحدَّث لأول مرة: ينسخها مستورد أحادي الاتجاه (مع المحادثات وتقدّم التعلّم والمجلدات والوسائط) إلى المالك الذي يحدده الخادم لذلك المتصفح، ويترك البيانات الأصلية في المتصفح دون تغيير. يجري الاستيراد مرة واحدة لكل متصفح: يربط الخادم المتصفح بأول مالك يفتح التطبيق المُحدَّث فيه، وينقل الضمّ هذا الربط إلى الحساب (مالك مجهول دُمج في حساب عند تسجيل الدخول)، ويرفض الخادم طلبات المستورد لأي مالك آخر. ولا يُستورد شيء لأي مالك آخر يستخدم المتصفح نفسه لاحقًا. لا يتطلب ذلك أي إجراء ولا تظهر أي واجهة، وتُسجَّل المشكلات في وحدة تحكم المتصفح بالبادئة [legacy-browser-import].

الترقية من 1.1.x

النشر ما يجب فعله
docker compose اسحب الإصدار الجديد وشغّل docker compose up --build. انقل إعدادات النماذج إلى openmaic.yml أو الإعدادات ← النماذج (تبقى متغيرات المزودين صالحة ما دام لا يوجد openmaic.yml)، وأعد كتابة MODEL_ROUTES على شكل خانات: يرفض الخادم البدء إذا ضُبط دون openmaic.yml. افتح التطبيق مرة واحدة في كل متصفح يحتوي على مقررات محلية لاستيرادها. إذا كان .env.local يضبط PERSISTENCE_SHARED_OWNER_ID فاضبط أيضًا OWNER_SINGLE_USER=false: يبدأ Compose الآن في وضع المستخدم الواحد. لا تُدمج مقررات المالكين المجهولين من نشر سابق على الخادم في المستخدم الواحد تلقائيًا؛ استعدها عبر POST /api/identity/claim من كل متصفح أنشأها (فتح المتصفح يستورد فقط ما احتفظ به محليًا).
pnpm start مع PostgreSQL خاصة بك أوقف الخادم، واجلب الإصدار الجديد، وشغّل pnpm install وpnpm build، ثم أعد التشغيل بـ pnpm start في مجلد العمل نفسه ليحتفظ بـ data/. اضبط DATABASE_URL ووضع الهوية، وOPENMAIC_SECRET_KEY عندما تتشارك عدة نسخ قاعدة البيانات؛ وتحتاج النسخ المتعددة أيضًا إلى مجلد بيانات مشترك واحد (راجع الإعدادات المطلوبة). إعدادات النماذج وMODEL_ROUTES ومقررات المتصفح كما في الصف السابق.
Vercel ابقَ على 1.1.x، أو انتقل إلى مضيف يعمل باستمرار بقاعدة البيانات نفسها (انظر أدناه).
Skill / API اتبع مهارة openmaic المحدّثة: ارفع المواد وأرسل الطلب واستعلم عن الحالة بصفة المالك نفسه.

قبل التشغيل الأول للإصدار 1.2.0:

  • احتفظ بمجلد البيانات. شغّل 1.2.0 مع مجلد data/ نفسه في مجلد العمل (أو OPENMAIC_CLASSROOMS_DIR نفسه)، ومع وحدة التخزين openmaic-data نفسها في Compose. الفصول التي حفظها 1.1.x ملفاتٍ في data/classrooms تُستورد إلى قاعدة البيانات مرة واحدة في الخلفية بعد التشغيل؛ والخادم الذي يبدأ دون هذا المجلد لا يجد ما يستورده.
  • اضبط وضع الهوية أولًا. يمنح الاستيراد تلك الفصول للمالك المضبوط عند ذلك التشغيل: المستخدم الواحد (OWNER_SINGLE_USER=true) أو مالك الفريق المشترك (PERSISTENCE_SHARED_OWNER_ID)، وإلا (الوضع المجهول) فالمالك المخصص system:legacy-classrooms، وعندها تُفتح للقراءة فقط من روابطها القديمة ولا تظهر في أي مكتبة. يُستورد كل فصل مرة واحدة فقط، لذا تبقى الفصول المستوردة في الوضع المجهول لدى system:legacy-classrooms حتى لو ضبطت وضع هوية لاحقًا.
  • اذكر النماذج. يحتاج كل مزود يستخدم بيانات اعتماد يديرها الخادم ويرسل النموذج المختار إلى الخدمة الأصلية (المحادثة والصور والفيديو وتحويل النص إلى كلام والتعرف على الكلام والبحث على الويب المعتمد على النماذج) إلى قائمة نماذج صريحة (*_MODELS، أو models تحت المزود في openmaic.yml أو server-providers.yml). من دونها لا يُفوَّض إلا النموذج الافتراضي والنموذج الاحتياطي والنماذج التي تشير إليها الخانات، ويذكر سجل التشغيل كل مزود من هذا النوع بلا قائمة. لا تحتاج الخدمات التي لا تختار نموذجًا، مثل Tavily أو محلل المستندات، إلى قائمة. راجع تفويض النماذج.
  • رقِّ خدمة العرض مع التطبيق. إذا كنت تستخدم تصدير فيديو MP4، فرقِّ كل مثيلات render-service مع التطبيق أو قبله: مهام العرض مرتبطة بمالكها، والخليط من الإصدارات القديمة والجديدة يرفض الطلبات.
  • تحقق من نموذج الفصل. ترفض محادثة الفصل في Pi النموذج الذي يذكر الكتالوج المدمج أنه لا يدعم استدعاء الأدوات (tool_calling_required)، وترفض عنوان Gemini المتوافق مع OpenAI من Google (gemini_native_required). اضبط Gemini بالإعداد المسبق الأصلي google؛ راجع موفرو LLM.

تُستورد بيانات المتصفح (المقررات وإعدادات النماذج والوكلاء المخصصون) مرة واحدة عندما يفتح كل متصفح التطبيق المحدّث لأول مرة. راجع الإعدادات لنقل إعدادات النماذج، وCHANGELOG للقائمة الكاملة للتغييرات الكاسرة.

Vercel (حتى 1.1.x)

النشر بلا خادم مدعوم حتى OpenMAIC 1.1.x، ويشرح README المستودع كيفية نشر الفرع release/1.1.x على Vercel. يمكن نقل مثل هذا النشر لاحقًا إلى مضيف يعمل باستمرار دون فقدان البيانات: وجّه DATABASE_URL في المضيف الجديد إلى قاعدة البيانات التي كان يستخدمها (إن وُجدت)، وقدّمه على العنوان نفسه؛ فالمتصفحات تحفظ بياناتها لكل موقع، وتُستورد مقررات الزوار عندما يفتح كل متصفح التطبيق المحدّث لأول مرة.

اختياري: تصدير فيديو MP4

تقوم ميزة "تصدير الفيديو" أولاً بإنشاء مشروع Hyperframes مستقل في المتصفح، ثم يستخدم خدمة render-service المستقلة Chromium وFFmpeg لتصيير MP4. هذه الخدمة اختيارية ولا تؤثر على إنشاء الدروس العادي.

لتفعيل ملف التعريف video-export:

bash
docker compose --profile video-export up --build

يتيح Compose لـ OpenMAIC الاتصال بخدمة التصيير عبر RENDER_SERVICE_URL. إذا لم يتم تفعيل هذا الملف التعريفي أو كانت خدمة التصيير غير متاحة، يتحول التصدير إلى تنزيل مشروع ZIP للتصيير المحلي عبر CLI. تستخدم خدمة التصيير شبكة معزولة وتتطلب قدرة NET_ADMIN عند التشغيل؛ لمزيد من القيود وطرق النشر المستقلة، راجع render-service/README.md. عند الترقية، رقِّ خدمة العرض مع التطبيق أو قبله.

متغيرات البيئة

راجع الإعدادات لضبط النماذج (openmaic.yml وإعدادات النماذج) ولجميع متغيرات البيئة. يجب ضبط نموذج محادثة واحد على الأقل قبل توليد المقررات.

التحكم في الوصول

يمكن تعيين ACCESS_CODE لحماية العرض التوضيحي المشترك بكلمة مرور على مستوى الموقع. راجع شرح الإعدادات → ACCESS_CODE.

خدمات الاستضافة الذاتية الاختيارية


Packages Docs Content Docs Deployment.Ja (`packages/docs/content/docs/deployment.ja.mdx`)


title: デプロイ

OpenMAIC は Next.js アプリケーションで、常駐する Node.js プロセスと PostgreSQL が必要です。授業の生成はサーバー側で行われます。各生成はデータベースに記録され、サーバープロセス内のワーカーが実行します。ブラウザがページを離れても生成は続き、プロセスが停止した場合は別のプロセス(または再起動した同じプロセス)が最後に完了したステップから引き継ぎます。サポートするデプロイ方法は次の 2 つです。

  • Docker Compose(推奨):アプリと PostgreSQL の 2 つのコンテナ。
  • Docker イメージまたは pnpm start:自分で運用する PostgreSQL と組み合わせます。

1.2.0 以降、Vercel などのサーバーレス環境はサポートしません。リクエストとともに終了する関数では生成を実行できないためです。下記の「Vercel(1.1.x まで)」を参照してください。

Docker

リポジトリには本番向けの Dockerfile が含まれています。イメージは Node.js 22 を使用しています。ビルドして実行します:

bash
docker build -t openmaic .

docker run --env-file .env.local -e DATABASE_URL=postgres://user:password@db-host:5432/openmaic -v openmaic-data:/app/data -p 3000:3000 openmaic

リポジトリにある Docker Compose 設定の利用を推奨します:

bash
cp .env.example .env.local
# .env.local(キーとサーバーオプション)を編集してから:
docker compose up --build

デフォルトの Compose デプロイは OpenMAIC アプリと PostgreSQL の 2 つのコンテナを起動し、両者の間の DATABASE_URL を設定して、openmaic-data と openmaic-postgres ボリュームをマウントします。その他のプロバイダーや機能は必要に応じて設定してください。詳細は設定説明を参照してください。

モデルの設定方法は 2 通りあります。個人利用のインストールでは、スタックを起動してアプリを開き、モデル設定でモデルサービスを接続します。設定はデータベースに保存されます。ファイルで管理するデプロイでは、openmaic.example.yml を openmaic.yml にコピーし、キーを .env.local に置いてファイルをマウントします(後述)。設定説明を参照してください。

イメージはボリュームのデータディレクトリを実行ユーザーの所有で作成するため、新しいボリュームではそのまま授業と利用記録が保存されます。古いイメージが作成したボリュームは root 所有の場合があり、その場合アプリは通常どおり動作しているのに、サーバー側の保存がすべて EACCES で失敗します(授業は気づかれないままディスクに書き込まれず、ログに Failed to record usage ... EACCES: permission denied が出ます)。このようなボリュームは一度修復してから、アプリを再起動してください。

bash
docker exec -u root <container> chown -R 1001:1001 /app/data

NEXT_PUBLIC_* の機能フラグは Docker ビルド時に注入されるため、実行時の .env.local に書くだけでは反映されません。例えば動画エクスポートと実験的な PPTX インポートを有効にする場合:

bash
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true \
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true \
docker compose --profile video-export up --build

他のクライアント機能フラグも同様に渡せます。docker build を使う場合は対応する --build-arg を利用してください。

openmaic.yml を使うには、アプリの作業ディレクトリにマウントするか、別の場所にマウントして OPENMAIC_CONFIG でそのパスを指定します。

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

ホストに存在しないパスはマウントしないでください。Docker がその場所にディレクトリを作成し、サーバーが起動を拒否します。docker run では -v "$PWD/openmaic.yml:/app/openmaic.yml:ro" を追加します。ファイルを編集したらコンテナを再起動してください。openmaic.yml は .dockerignore に含まれているため、リポジトリからビルドしたイメージに焼き込まれることはありません。実行時にマウントしてください。openmaic.yml がない場合は、旧来の server-providers.yml が引き続き /app/server-providers.yml から読み込まれます。

Docker コンテナ内の localhost はコンテナ自身を指します。ホスト上で Ollama、Lemonade、VoxCPM、ComfyUI を動かしている場合は、http://host.docker.internal:11434/v1 や http://host.docker.internal:8188 のように host.docker.internal を使ってください。Linux の Docker では通常、openmaic サービスに extra_hosts: ["host.docker.internal:host-gateway"] も必要です。openmaic.yml(または旧来の変数)で宣言したプロバイダーはサーバー管理なので、そのエンドポイントは ALLOW_LOCAL_NETWORKS なしでホストやプライベートネットワーク上に置けます。この変数が関係するのはユーザーが入力するエンドポイント、つまり 設定 → モデルサービス で追加したチャットの Base URL と、非推奨のリクエストフィールドだけです。

自前の仮想マシンでのセルフホスト

ホストには Node.js 22.19.0 以上と pnpm 10.28.0 が必要です。pnpm でビルドして起動します:

bash
pnpm install
pnpm build
DATABASE_URL=postgres://user:password@db-host:5432/openmaic pnpm start      # デフォルトでポート 3000 をリッスンします

プロセスマネージャー(systemd、pm2、コンテナなど)で常時稼働させ、停止時に再起動されるようにしてください。TLS 終端は nginx や Caddy などを前段に置いてください。授業と生成された授業メディアは PostgreSQL(または S3)に保存されるため、データディレクトリと同じ OPENMAIC_SECRET_KEY もあわせて共有すれば、複数のインスタンスで 1 つのデータベースを共有できます(下記参照)。

pnpm start は next start を実行し、output: standalone 設定では動作しないという警告を表示します。この警告は無視して構いません。next start は .next からビルド全体を配信します。Docker イメージは代わりに standalone サーバー(node server.js)を実行しますが、このサーバーは作業ディレクトリを自身のフォルダーに切り替えます。そのため仮想マシンでは、起動したディレクトリから data/ と openmaic.yml を読み込む pnpm start を使い続けてください。

必須の設定

設定 Docker Compose Docker イメージまたは pnpm start
DATABASE_URL 同梱の PostgreSQL を指すよう設定済み 必須。未設定ならサーバーは起動時に終了します
ID モード シングルユーザー(docker-compose.defaults.env の OWNER_SINGLE_USER=true) 未設定ならブラウザごとに匿名オーナーになります
OPENMAIC_SECRET_KEY 任意。生成されたシークレットは openmaic-data ボリュームに保存されます 複数インスタンスの場合、または永続的な data/ ディレクトリがない場合は必須
データディレクトリ /app/data にマウントした openmaic-data ボリューム 作業ディレクトリの永続的な data/ ディレクトリ。すべてのインスタンスで共有
モデル openmaic.yml または 設定 → モデル 同左

ID モードは授業の所有者を決めます。未設定の場合、ブラウザごとに別々の匿名オーナーとなり、別のブラウザでは空のライブラリが表示されます。個人利用では OWNER_SINGLE_USER=true、1 つのチームで 1 つのライブラリを共有する場合は ACCESS_CODE とあわせて PERSISTENCE_SHARED_OWNER_ID を設定し、実際のアカウントが必要な場合はオーナー認証メソッドを登録してください。詳しくは Owner identity と .env.example を参照してください。

匿名モードでは、オーナー cookie は本番環境で Secure 属性付きになり、ACCESS_CODE の cookie も同様です。ブラウザが Secure cookie を保存するのは HTTPS か http://localhost の場合だけです(Safari は localhost でも保存しません)。そのため、プレーンな HTTP で提供するデプロイでは COOKIE_SECURE=0(値は正確に 0)を設定する必要があります。設定しないと、リクエストごとに新しいオーナーになって書き込みが 403 で失敗し、アクセスコードも繰り返し求められます。TLS がない場合 cookie は平文で送られるため、信頼できるネットワークでのみ行ってください。

localhost 以外のアドレス(LAN IP、Tailscale IP、MagicDNS 名)でのプレーンな HTTP もセキュアコンテキストではないため、ブラウザには Web Locks API がありません。OpenMAIC はその場合も保存しますが、同じコースの複数タブ間の調整は行われません。2 つのタブが同じチャットを変更した場合は、最後の保存が優先されます。また、一方のタブで削除したチャットは、もう一方のタブで編集中だった場合でも削除されたままになります。ネットワーク内のスマートフォンやタブレットから使う場合は、サーバーの前に tailscale serve を置くなどして HTTPS を使うことをおすすめします。

OPENMAIC_SECRET_KEY はモデル設定に保存されたプロバイダーキーを暗号化します。未設定の場合、各インスタンスが data/instance-secret.key に独自のシークレットを作成するため、データベースを共有するインスタンス同士で保存済みキーを読み取れません。データベースのバックアップと一緒に保管してください。シークレットと OPENMAIC_SECRET_KEY を参照してください。

データベースを共有する複数のインスタンスには、共有された永続的なデータディレクトリも 1 つ必要です(作業ディレクトリの data/、イメージでは /app/data。たとえば各インスタンスに同じボリュームやネットワークファイルシステムをマウントします)。授業ドキュメントと生成された授業メディアは PostgreSQL(ASSET_S3_BUCKET を設定した場合は S3)に保存されますが、次のファイルは各プロセスのデータディレクトリに置かれます:アップロードされた資料ファイルとその抽出結果、エージェントが編集する授業のナレーションと資料メディア(data/classrooms、または OPENMAIC_CLASSROOMS_DIR)、利用記録(data/usage)、自動生成されたインスタンスシークレット。別のインスタンスが引き継いだ生成は、アップロードされた資料をここから読み込みます。

生成の実行

すべてのサーバープロセスで生成ワーカーが動作し、OPENMAIC_AGENT_RUNTIME_ENABLED には依存しません。次の変数で調整できます(値は正の整数)。

変数 デフォルト 意味
OPENMAIC_MAX_ACTIVE_RUNS_PER_OWNER 2 1 人のオーナーが同時に進行できる生成の数。アウトラインの確認待ちや、失敗したステップで一時停止している生成は数えません。上限を超えると、開始・アウトラインの確認・リトライは 429 ACTIVE_RUN_LIMIT を返します。
OPENMAIC_MAX_WAITING_RUNS_PER_OWNER 10 1 人のオーナーが同時にアウトラインの確認待ちにできる生成の数。
OPENMAIC_GENERATION_RUN_MAX_CONCURRENT 4 1 つのプロセスが同時に実行する生成の数。
OPENMAIC_GENERATION_RUN_STREAMS_PER_OWNER 16 1 人のオーナーが 1 つのプロセスで同時に開ける生成イベントストリームの数。
OPENMAIC_GENERATION_RUN_RETENTION_HOURS 24 終了した生成が完全なイベントログを保持する時間(時間単位)。その後は圧縮されます。

生成はエージェントランタイムのリース設定(OPENMAIC_AGENT_RUNTIME_SCAN_INTERVAL_MS、_HEARTBEAT_MS、_LEASE_TTL_MS、_MAX_ATTEMPTS。.env.example を参照)を共有します。サーバーの停止や再起動(ローリングアップデートを含む)の際は、実行中の生成を別のインスタンスか再起動した同じインスタンスが、最後に完了したステップから続けます。進行はリースの有効期間(既定 10 秒)ほど止まり、実行中だったステップはもう一度生成されます。ステップを完了しないまま OPENMAIC_AGENT_RUNTIME_MAX_ATTEMPTS 回(既定 5 回)続けて中断された生成は、そのステップで一時停止し再試行できる状態になります。多数のインスタンスのローリングアップデートでこの回数に達する場合は値を大きくしてください。

サーバー側永続化(PostgreSQL)

OpenMAIC は授業、チャット履歴、学習の進捗、生成メディアを PostgreSQL に保存するため、DATABASE_URL は必須です。未設定の場合、サーバーは [boot] Invalid server configuration; the server will not start: DATABASE_URL is not set. ... と対処方法を出力し、終了コード 1 で終了します。永続化用の HTTP API は OpenMAIC に組み込まれており、追加の永続化サービスは不要です。

docker compose up はアプリの横で PostgreSQL を起動し、DATABASE_URL を設定します。ローカル開発では、pnpm db:up が同じサービス定義から独立した開発用データベース(専用の Compose プロジェクトとボリューム openmaic-dev-db。このマシン上のすべてのチェックアウトで共有。スタックのデータベースには影響しません)を 127.0.0.1(ポート OPENMAIC_DB_PORT、既定 5432)で起動し、pnpm db:down で停止します。その後 .env.local に接続情報を設定します:

bash
DATABASE_URL=postgres://openmaic:[email protected]:5432/openmaic

永続化専用の認証情報はありません。各リクエストはオーナー解決で得られたオーナー(既定ではブラウザごとの匿名 Cookie)に帰属し、ランタイムセッションとメディアはそのオーナーごとに分離されます。既定の識別はブラウザを分離するだけで誰も認証しないため、実ユーザー向けのデプロイでは自前のアカウントに基づくオーナー認証メソッドを登録してください。以前のリリースの PERSISTENCE_DEV_TOKEN / NEXT_PUBLIC_PERSISTENCE_TOKEN は削除され無視されます。旧来のブラウザ生成の学習者キーで保存されたランタイムセッションにはアクセスできなくなります。PERSISTENCE_DEV_TOKEN が唯一のアクセス制御だった場合は、アップグレード前に ACCESS_CODE やゲートウェイで保護するか、オーナー認証メソッドを登録してください。

PostgreSQL のデータは openmaic-postgres ボリュームに保存されます。PERSISTENCE_POSTGRES_PASSWORD はデータベースディレクトリ初回初期化時にのみパスワードを設定し、その後の環境変数変更では既存ユーザーのパスワードは自動的に変更されません。

OPENMAIC_SECRET_KEY を設定していない場合、openmaic-data ボリュームには instance-secret.key も保存されます。これは自動生成されたインスタンスシークレットで、モデル設定に保存されたプロバイダーのキーを暗号化します。データベースと一緒にバックアップしてください。これがないと、保存済みのキーを再入力する必要があります。複数のレプリカで動かす場合や、永続ボリュームがない・ファイルシステムが読み取り専用の場合は、代わりに OPENMAIC_SECRET_KEY を設定してください(全レプリカで同じ値)。シークレットと OPENMAIC_SECRET_KEY を参照してください。

以前のリリースでは授業をブラウザ(IndexedDB)に保存できました。このモードは廃止され、NEXT_PUBLIC_PERSISTENCE ビルド変数は無視されます。その方法でブラウザに保存された授業は、そのブラウザでアップグレード後のアプリを初めて開いたときに自動的にサーバーへ移されます。一方向のインポーターが、それらを(チャット、学習の進捗、フォルダー、メディアとともに)サーバーがそのブラウザについて解決した所有者にコピーし、ブラウザ内の元のデータには手を加えません。インポートはブラウザごとに一度だけです。サーバーはそのブラウザを、アップグレード後のアプリを最初に読み込んだ所有者に紐付け、統合(サインイン時に匿名の所有者がアカウントに統合されること)によって紐付けはアカウントに移ります。サーバーはそれ以外の所有者のためのインポーターの要求を拒否します。それ以外に同じブラウザを後から使う所有者には何もインポートされません。操作は不要で、UI も表示されません。問題があればブラウザのコンソールに [legacy-browser-import] という接頭辞で記録されます。

1.1.x からのアップグレード

デプロイ 手順
docker compose 新しいリリースを取得し、docker compose up --build を実行します。モデル設定を openmaic.yml または 設定 → モデル に移し(openmaic.yml がない間はプロバイダーの環境変数も引き続き使えます)、MODEL_ROUTES をスロットとして書き直してください。openmaic.yml なしで MODEL_ROUTES を設定したサーバーは起動を拒否します。ローカルに授業があるブラウザでは、インポートのためにアプリを一度開いてください。 .env.local で PERSISTENCE_SHARED_OWNER_ID を設定している場合は、OWNER_SINGLE_USER=false も設定してください(Compose はシングルユーザーモードで起動するようになりました)。以前のサーバー側デプロイで匿名オーナーが持っていた授業は、シングルユーザーに自動では統合されません。それらを作成した各ブラウザから POST /api/identity/claim で引き継いでください(ブラウザを開いてインポートされるのは、そのブラウザがローカルに保存していたデータだけです)。
pnpm start と自前の PostgreSQL サーバーを停止し、新しいリリースを取得して pnpm install と pnpm build を実行し、同じ作業ディレクトリで pnpm start で再起動します。こうすると data/ が引き継がれます。DATABASE_URL と ID モードを設定し、複数インスタンスでデータベースを共有する場合は OPENMAIC_SECRET_KEY も設定します。複数インスタンスには共有のデータディレクトリも 1 つ必要です(必須の設定を参照)。モデル設定、MODEL_ROUTES、ブラウザの授業は上の行と同じです。
Vercel 1.1.x にとどまるか、同じデータベースを使う常駐ホストに移行します(下記)。
Skill / API 更新された openmaic skill に従い、同じオーナーとして資料をアップロード、送信、ポーリングします。

1.2.0 を初めて起動する前に:

  • データディレクトリを残してください。 1.2.0 は作業ディレクトリに同じ data/ がある状態で(または同じ OPENMAIC_CLASSROOMS_DIR で)起動します。Compose では同じ openmaic-data ボリュームを使います。1.1.x が data/classrooms にファイルとして保存した授業は、起動後にバックグラウンドで一度だけデータベースへインポートされます。このディレクトリなしで起動したサーバーにはインポートするものがありません。
  • ID モードを先に設定してください。 インポートされた授業は、その起動時に設定されているオーナーのものになります。シングルユーザー(OWNER_SINGLE_USER=true)またはチーム共有オーナー(PERSISTENCE_SHARED_OWNER_ID)が設定されていればそのオーナー、そうでなければ(匿名モード)専用オーナー system:legacy-classrooms です。後者の場合、授業は以前のリンクから読み取り専用で開け、どのライブラリにも表示されません。各授業のインポートは一度だけなので、匿名モードでインポートされた授業は、後から ID モードを設定しても system:legacy-classrooms のままです。
  • モデルを列挙してください。 サーバー管理の認証情報を使い、選択されたモデルを上流へ送るプロバイダー(チャット、画像、動画、音声合成、音声認識、モデルベースの Web 検索)には、明示的なモデルリスト(*_MODELS、または openmaic.yml・server-providers.yml のプロバイダー配下の models)が必要です。リストがない場合、認可されるのは既定・フォールバック・スロットで参照されるモデルだけで、リストのないこうしたプロバイダーは起動ログに表示されます。Tavily やドキュメントパーサーなど、モデル選択のないサービスにリストは不要です。モデル認可を参照してください。
  • render-service はアプリと一緒に更新してください。 MP4 動画エクスポートを使っている場合は、すべての render-service インスタンスをアプリと同時か、それより先に更新してください。レンダリングジョブはオーナーに紐づき、新旧バージョンが混在すると安全側に失敗します(リクエストを拒否します)。
  • 教室のモデルを確認してください。 Pi 教室チャットは、組み込みカタログでツール呼び出し非対応とされているモデル(tool_calling_required)と、Google の OpenAI 互換 Gemini エンドポイント(gemini_native_required)を拒否します。Gemini はネイティブの google プリセットで設定してください。LLM プロバイダーを参照してください。

ブラウザのデータ(授業、モデル設定、カスタムエージェント)は、各ブラウザがアップグレード後のアプリを初めて開いたときに一度だけインポートされます。モデル設定の移行は設定を、破壊的変更の一覧は CHANGELOG を参照してください。

Vercel(1.1.x まで)

サーバーレスでのデプロイは OpenMAIC 1.1.x までサポートされ、release/1.1.x ブランチを Vercel にデプロイする手順はリポジトリの README にあります。このようなデプロイは、後でデータを失わずに常駐ホストへ移行できます。新しいホストの DATABASE_URL を(使っていた場合は)元のデータベースに向け、同じアドレスで公開してください。ブラウザはサイトごとにデータを保存しており、訪問者のブラウザにある授業は、各ブラウザがアップグレード後のアプリを初めて開いたときにインポートされます。

オプション:MP4 動画エクスポート

「動画エクスポート」機能は、まずブラウザで自己完結型の Hyperframes プロジェクトを生成し、独立した render-service が Chromium と FFmpeg を使って MP4 にレンダリングします。このサービスは任意であり、通常の授業生成には影響しません。

video-export プロファイルを有効にします:

bash
docker compose --profile video-export up --build

Compose は RENDER_SERVICE_URL を通じて OpenMAIC とレンダリングサービスを接続します。このプロファイルを有効にしないか、レンダリングサービスが利用できない場合は、エクスポートはプロジェクト ZIP のダウンロードにフォールバックし、ローカル CLI でのレンダリングを想定します。レンダリングサービスは分離されたネットワークで動作し、起動時に NET_ADMIN 権限が必要です。詳細や独立デプロイ方法はリポジトリの render-service/README.md を参照してください。アップグレード時は、レンダリングサービスをアプリと同時か、それより先に更新してください。

環境変数

モデルの設定(openmaic.yml とモデル設定)とすべての環境変数は設定説明を参照してください。コースを生成するには、少なくとも 1 つのチャットモデルを設定する必要があります。

アクセス制御

共有デモでは ACCESS_CODE を設定してサイト全体にパスワードをかけることができます。詳細は設定説明 → ACCESS_CODEを参照してください。

オプションのセルフホストサービス


Packages Docs Content Docs Deployment (`packages/docs/content/docs/deployment.mdx`)


title: Deployment

OpenMAIC is a Next.js application that needs a long-running Node.js process and PostgreSQL. Course generation runs on the server: each run is stored in the database and executed by a worker inside the server process, which keeps going after the browser leaves the page and is taken over by another process (or the same one after a restart) from its last completed step. Two deployments are supported:

  • Docker Compose (recommended): the app and PostgreSQL in two containers.
  • The Docker image or pnpm start with a PostgreSQL database you run yourself.

Serverless hosts such as Vercel are not supported from 1.2.0: a function that ends with its request cannot run a generation. See Vercel (up to 1.1.x).

Docker

The repository includes a production-ready Dockerfile. The image uses Node.js 22 internally. Build and run it with:

bash
docker build -t openmaic .

docker run --env-file .env.local -e DATABASE_URL=postgres://user:password@db-host:5432/openmaic -v openmaic-data:/app/data -p 3000:3000 openmaic

We recommend the repository's Docker Compose configuration:

bash
cp .env.example .env.local
# Edit .env.local (keys and server options), then:
docker compose up --build

The default Compose deployment starts two containers, the OpenMAIC app and PostgreSQL, wires DATABASE_URL between them, and mounts the openmaic-data and openmaic-postgres volumes. Configure other providers and features as needed; see Configuration.

Models are configured in one of two ways. For a personal installation, start the stack, open the app and connect a model service in the model settings; the configuration is stored in the database. For a deployment you manage as files, copy openmaic.example.yml to openmaic.yml, keep the keys in .env.local and mount the file (see below). See Configuration.

The image creates the volume's data directory with the runtime user's ownership, so fresh volumes persist classrooms and usage records out of the box. A volume created by an older image may be root-owned, which makes every server-side save fail with EACCES while the app keeps serving normally (classrooms silently never reach disk; the log shows Failed to record usage ... EACCES: permission denied). Repair such a volume once, then restart the app:

bash
docker exec -u root <container> chown -R 1001:1001 /app/data

NEXT_PUBLIC_* feature flags are injected at Docker build time and cannot be set only in the runtime .env.local. For example, to enable video export and experimental PPTX import:

bash
NEXT_PUBLIC_ENABLE_VIDEO_EXPORT=true \
NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true \
docker compose --profile video-export up --build

Other client-side feature flags can be passed in the same way. When using docker build, use the corresponding --build-arg options instead.

To use openmaic.yml, mount it at the app's working directory, or anywhere else with OPENMAIC_CONFIG pointing at it:

yaml
services:
  openmaic:
    volumes:
      - ./openmaic.yml:/app/openmaic.yml:ro

Do not mount a path that does not exist on the host: Docker creates a directory in its place, and the server refuses to start. With docker run, add -v "$PWD/openmaic.yml:/app/openmaic.yml:ro". Restart the container after editing the file. openmaic.yml is listed in .dockerignore, so it is never baked into an image built from the repository: mount it at run time instead. The legacy server-providers.yml is still read from /app/server-providers.yml when there is no openmaic.yml.

Inside a Docker container, localhost refers to the container itself. If Ollama, Lemonade, VoxCPM, or ComfyUI runs on the host, use host.docker.internal, for example http://host.docker.internal:11434/v1 or http://host.docker.internal:8188. Linux Docker usually also requires extra_hosts: ["host.docker.internal:host-gateway"] on the openmaic service. Providers declared in openmaic.yml (or through the legacy variables) are server-managed, so their endpoints may be on the host or a private network without ALLOW_LOCAL_NETWORKS. That variable only matters for endpoints a user types: chat base URLs added in Settings → Model Services, and the deprecated request fields.

Self-host on a VM

The host needs Node.js 22.19.0 or later and pnpm 10.28.0. Build and start with pnpm:

bash
pnpm install
pnpm build
DATABASE_URL=postgres://user:password@db-host:5432/openmaic pnpm start      # listens on port 3000 by default

Run it under a process manager (systemd, pm2, a container) that keeps it running and restarts it, and put nginx or Caddy in front for TLS termination. Courses and generated course media are stored in PostgreSQL (or S3), so several instances can share one database, as long as they also share the data directory and OPENMAIC_SECRET_KEY (see below).

pnpm start runs next start, which warns that it does not work with the output: standalone setting. The warning is harmless: next start serves the complete build from .next. The Docker image runs the standalone server instead (node server.js), but that server changes its working directory to its own folder, so on a VM keep pnpm start, which reads data/ and openmaic.yml from the directory it starts in.

Required configuration

Setting Docker Compose Docker image or pnpm start
DATABASE_URL set for the bundled PostgreSQL required; the server exits at startup without it
Identity mode single user (OWNER_SINGLE_USER=true in docker-compose.defaults.env) one anonymous owner per browser unless you choose one
OPENMAIC_SECRET_KEY optional: a generated secret is kept in the openmaic-data volume required for several instances, or without a persistent data/ directory
Data directory the openmaic-data volume at /app/data a persistent data/ directory in the working directory, shared by every instance
Models openmaic.yml or Settings → Models the same

Identity mode decides who owns a course. Without one, every browser is its own anonymous owner and a second browser sees an empty library. Set OWNER_SINGLE_USER=true for a personal installation, PERSISTENCE_SHARED_OWNER_ID together with ACCESS_CODE for one team sharing one library, or register owner auth methods for real accounts; see Owner identity and .env.example.

In anonymous mode the owner cookie carries Secure in production, and so does the ACCESS_CODE cookie. Browsers keep a Secure cookie only over HTTPS or on http://localhost (Safari