config/config.exs

main at 58e6347eeb72 · 30 KB

# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.

# General application configuration
import Config

config :phoenix,
  filter_parameters: [
    "authorization",
    "code",
    "content",
    "credential",
    "memory",
    "messages",
    "password",
    "poll_secret",
    "prompt",
    "raw_arguments",
    "sdp",
    "secret",
    "state",
    "token",
    "transcript",
    "verifier"
  ]

config :openagents,
  namespace: OpenAgents,
  ecto_repos: [OpenAgents.Repo],
  generators: [timestamp_type: :utc_datetime],
  runtime_environment: :development,
  runtime_role: :web,
  staging_gate: 0,
  staging_cleanup_enabled: false,
  production_deploy_enabled: false,
  repository_provisioner_enabled: true,
  build_revision: "image",
  image_digest: nil,
  migrate_on_boot: false,
  secure_cookies: false,
  https_aliases: [],
  conversation_page_size: 40,
  maximum_message_bytes: 8_000,
  turn_rate_limit: 50,
  admin_github_ids: [],
  computer_controller_enabled: false,
  machine_token_ttl_seconds: 2_592_000,
  coding_jobs_dir: "/var/lib/openagents/coding-jobs",
  posthog_project_token: nil,
  posthog_api_host: "https://us.i.posthog.com",
  posthog_analytics: [
    personal_api_key: nil,
    project_id: nil,
    app_host: "https://us.posthog.com"
  ],
  work_workers_enabled: false,
  work: [enabled: false],
  # The durable effect outbox (EFFECT-001). The table is always written; the
  # worker that drains it is what this gates, because a host that should not
  # execute effects must not claim one.
  effects: [
    worker_enabled: false,
    interval_ms: 1_000,
    batch_limit: 20,
    lease_seconds: 120,
    backoff_base_ms: 1_000,
    backoff_ceiling_ms: 300_000
  ],
  scv_codex: [
    enabled: false,
    execution_reaper_enabled: false,
    executable: "/usr/local/bin/codex",
    credential_store: OpenAgents.SCV.CodexCredentialStore.File,
    credential_refs: ["file:operator-1"],
    file_root: "/var/lib/openagents/scv/codex-accounts",
    temporary_root: System.tmp_dir!(),
    client_options: []
  ],
  scv_deploy: [
    enabled: false,
    model: "opencode/x-preview-f-free",
    reasoning_effort: "low",
    opencode_api_key: nil,
    executable: nil,
    concurrency_limit: 2,
    wall_clock_ms: 900_000,
    maximum_output_bytes: 16_777_216,
    output_root: "/var/lib/openagents/scv/opencode-runs"
  ],
  tools_enabled: true,
  voice: [
    enabled: false,
    architecture: :openai_realtime,
    provider: "openai",
    model: "gpt-realtime-2.1",
    voice: "marin",
    reasoning_effort: "low",
    maximum_session_seconds: 3_000
  ],
  voice_attempt_limit: 6,
  voice_attempt_window_seconds: 600,
  voice_maximum_concurrent_sessions: 8,
  voice_maximum_session_tokens: 5_000_000,
  voice_maximum_response_output_tokens: 4_096,
  voice_maximum_estimated_cost_microusd: 20_000_000,
  voice_operational_retention_days: 90,
  voice_compaction_input_token_threshold: 16_000,
  provider: OpenAgents.Providers.OpenAI,
  openrouter_provider: OpenAgents.Providers.OpenRouter,
  vercel_gateway_provider: OpenAgents.Providers.VercelGateway,
  openai_model: "gpt-5.6-luna",
  openai_api_key: nil,
  openrouter_api_key: nil,
  # The OpenRouter spelling of GLM 5.3 Flash. `stealth/ox-alpha` was this same
  # model under its pre-launch name, and OpenRouter now answers that slug with
  # a 404 that says so. Note the hyphen: OpenRouter writes the creator
  # `z-ai`, the Vercel gateway writes it `zai`, and the two are not
  # interchangeable.
  openrouter_model: "z-ai/glm-5.3-flash",
  # The typed model catalog (`OpenAgents.Inference.Models`, PROVIDER-002):
  # every model this deployment serves, in the order a client should offer
  # them; the first entry is the default. `id` is the public name a caller
  # asks for, `provider_model` the string the vendor is called with, and
  # either may be `{:config, key}` to follow a runtime-configurable value.
  # `provider` names a lane whose adapter module and credential are configured
  # separately, so no secret lives here; a lane whose credential is absent is
  # listed as unavailable, never silently substituted. `max_output` is the
  # per-call output cap the proxy's adapters send for that model, and
  # `context_window` is the ceiling this deployment publishes for the lane.
  #
  # A model may declare a `pricing` map with `input_per_million_tokens`,
  # `output_per_million_tokens`, and optionally `cached_input_per_million_tokens`.
  # A pricing map also names itself: `id` is the rate table every usage record
  # priced against it stamps as `pricing_id`, and `source` is where the rates
  # came from — `:declared` for a provider's published rates, anything else
  # (including omitting it) for a working figure. `OpenAgents.Inference.Pricing`
  # reads that word, and only `:declared` is billable (METER-001).
  #
  # Every rate below is a placeholder: no operator has declared any of them, so
  # all of them say `:placeholder` and nothing may bill from a cost they
  # produced. Replacing them is an owner action: enter the provider's rates and
  # set `source: :declared` in the same edit.
  #
  # The GLM 5.3 Flash figures are the ones to be careful with. Its posted rates
  # on 2026-08-26 were $0.075 in, $0.25 out, and $0.015 cached in per million
  # tokens — a limited-time half-price offer that ends 2026-09-09 16:00 UTC,
  # after which list price is $0.15, $0.50, and $0.03. The entry carries list
  # price, because a figure that silently doubles in two weeks is worse than
  # one that is honestly high, and because neither figure is declared either
  # way. The Gemini figures were written to make the system run and were not
  # read off any price page.
  #
  # A model with no `pricing` key records no estimated cost at all — not a zero
  # — and its usage records stamp `pricing_id: "unpriced"`. No entry below is in
  # that state now that `gpt-5.6-luna` has been withdrawn, but the state is
  # still reached: a call the gateway's fallback chain rescues is answered by a
  # model this catalog does not admit and therefore has no rates. Every read
  # surface says `unpriced` rather than `$0.00`.
  #
  # This list is exactly three models, and a caller may spend nothing else. A
  # model absent from it cannot be minted a grant (`OpenAgents.Inference.mint/1`
  # refuses the name) and cannot be called on a grant minted before it was
  # withdrawn (`OpenAgentsWeb.InferenceProxyController` refuses
  # `model_unavailable`). Withdrawing an entry is therefore a real withdrawal
  # rather than a hidden one.
  #
  # GLM 5.3 Flash leads, so it is what a caller that names none gets: a million
  # tokens of context, and cheap enough per token to hold an ordinary
  # conversation on a model that reasons before it answers. Its thinking is
  # charged against `max_output` before a word of the answer is — a 256-token
  # allowance was spent 243 tokens deep in reasoning and the answer was cut off
  # mid-word on `finish_reason: "length"` — so the allowance below is the
  # ceiling the gateway publishes for the lane rather than a modest figure.
  #
  # `ox-alpha`, which used to be listed here on the OpenRouter lane, was this
  # same model under its pre-launch name. OpenRouter now answers
  # `stealth/ox-alpha` with a 404 saying so, which is why it is gone rather
  # than demoted: there was never a second model to keep. The old public id
  # deliberately does not resolve to the new entry — silently answering a
  # withdrawn name with a survivor is what PROVIDER-002 forbids.
  #
  # Gemini 3.7 Flash is second: fast, a million tokens of context, and steady
  # enough to hold a conversation. It led this list until GLM 5.3 Flash was
  # added.
  #
  # The first two entries are on `:vercel_gateway`. `openrouter/free` is the
  # independent Coder Free lane, and it remains available when the gateway is
  # unavailable. OpenRouter limits that router to free models, so its declared
  # per-token rates are zero even though the selected provider model can vary.
  #
  # `vercel_gateway_fallback_models` below is a different list and it still ends
  # with `openai/gpt-5.6-luna`, deliberately. That is the gateway's own ordered
  # chain, tried inside a single call when the requested model fails, and it is
  # not a menu: nobody selects a model from it. Luna sitting last in it is the
  # backstop of a backstop. Withdrawing Luna from this catalog means no caller
  # can choose it; it does not mean the gateway may never fall back to it.
  #
  # A call the chain rescues is still attributed honestly. The adapter reads the
  # serving model back off the response, so a call answered by a model this
  # catalog does not admit records no price at all — `pricing_id: "unpriced"` —
  # rather than being charged at the rates of the model that was asked for
  # (METER-001, PROVIDER-002).
  model_catalog: [
    %{
      id: "glm-5.3-flash",
      # Through the Vercel gateway, which resolves the slug to z.ai and calls
      # it with the BYOK z.ai credentials this account holds there. The
      # `providerOptions.gateway.order` pin below names Vertex, which does not
      # serve this model; the gateway reports `"planningReasoning":"BYOK
      # credentials available for: zai"` and routes to z.ai anyway, so the pin
      # costs this lane nothing.
      provider: :vercel_gateway,
      provider_model: "zai/glm-5.3-flash",
      # A million, per z.ai's listing and the gateway's. OpenRouter publishes
      # 1,048,576 for the same model; a million is the smaller of the two and
      # the number the serving lane states.
      context_window: 1_000_000,
      # The gateway's published ceiling for this lane. It has to be large: the
      # model reasons against this allowance, so a small one is spent thinking
      # and the caller is handed a truncated answer on a 200.
      max_output: 131_000,
      # The declared promotion makes this lane free until the exclusive UTC
      # cutoff, after which pricing automatically returns to the regular table
      # without another deployment.
      pricing: %{
        id: "declared.glm-5.3-flash.v1",
        source: :declared,
        input_per_million_tokens: 150_000,
        output_per_million_tokens: 500_000,
        cached_input_per_million_tokens: 30_000,
        promotion: %{
          id: "declared.glm-5.3-flash.free-through-2026-08-31.v1",
          source: :declared,
          ends_at: ~U[2026-09-01 00:00:00Z],
          input_per_million_tokens: 0,
          output_per_million_tokens: 0,
          cached_input_per_million_tokens: 0
        }
      }
    },
    %{
      id: "gemini-3.7-flash",
      # Through the Vercel gateway, pinned to Vertex, so the call lands on
      # Google's hardware and spends this account's Google credits. OpenRouter
      # serves the same model and that lane worked, but its credits are not
      # these credits.
      provider: :vercel_gateway,
      provider_model: "google/gemini-3.7-flash",
      context_window: 1_048_576,
      max_output: 65_536,
      # Placeholder: the operator must set real provider rates and flip `source`
      # to `:declared` before anything bills from this. The cached-input rate is
      # optional and should be omitted if the provider does not offer one.
      pricing: %{
        id: "placeholder.gemini-3.7-flash.v1",
        source: :placeholder,
        input_per_million_tokens: 1_250_000,
        output_per_million_tokens: 10_000_000,
        cached_input_per_million_tokens: 100_000
      }
    },
    %{
      # The Coder Free lane resolves this exact fallback after checking for a
      # deployment-specific preferred free model. OpenRouter selects a current
      # free model behind this router id, so callers cannot be promised one
      # vendor context window. Its route only selects zero-price models.
      id: "openrouter/free",
      provider: :openrouter,
      provider_model: "openrouter/free",
      context_window: 32_768,
      max_output: 8_192,
      pricing: %{
        id: "declared.openrouter-free.v1",
        source: :declared,
        input_per_million_tokens: 0,
        output_per_million_tokens: 0,
        cached_input_per_million_tokens: 0
      }
    }
  ],
  gemini_api_key: nil,
  vercel_gateway_api_key: nil,
  # Try Vertex first for every model it serves here. The same Gemini slug is
  # also served by `google` — the Generative Language endpoint, which is not
  # where this account's credits are — but the fallback models are not on
  # Vertex, so Vercel may try other providers for them.
  vercel_gateway_providers: ["vertex"],
  # The gateway's ordered fallback chain, tried within one call when the
  # requested model fails. `zai/glm-5.3-flash` leads it because it is the only
  # entry this deployment also admits in `:model_catalog`: a rescued call that
  # lands on an admitted model is priced and attributed normally, and every
  # other entry here records no price at all. `openai/gpt-5.6-luna` stays last
  # at owner direction — it is the backstop of a backstop, reachable only
  # automatically and selectable by nobody.
  vercel_gateway_fallback_models: [
    "zai/glm-5.3-flash",
    "zai/glm-5.3",
    "zai/glm-5.2",
    "openai/gpt-5.6-luna"
  ],
  gemini_model: "gemini-3.7-flash",
  box_api_key: nil,
  box_api: [
    base_url: "https://ascii.dev/api/box/v1",
    ownership_marker: nil,
    reconciliation_receive_timeout_ms: 15_000,
    maximum_active_boxes: 10,
    default_maximum_active_boxes: 2,
    maximum_active_boxes_per_owner: 4,
    maximum_active_boxes_global: 20,
    estimated_burn_rate_per_box_hour_microusd: 100_000,
    maximum_burn_rate_per_conversation_microusd: 1_000_000,
    maximum_burn_rate_per_owner_microusd: 5_000_000,
    ttl_seconds: 3_600,
    idle_timeout_seconds: 1_800,
    reconciliation_interval_ms: 60_000,
    poll_interval_ms: 1_000,
    poll_attempts: 60,
    run_poll_interval_ms: 1_000,
    run_max_duration_seconds: 1_800,
    run_create_rate_limit: 10,
    run_command_rate_limit: 30,
    rate_limit_window_seconds: 60,
    create_rate_limit: 10,
    command_rate_limit: 30
  ],
  # The provider does not report a context window, so the `/chat` console shows
  # a context meter only where a deployment states one.
  openrouter_context_window: nil,
  # An override for the `/chat` streaming function. Tests set it; nothing else
  # does, so the console reaches OpenRouter everywhere else.
  chat_console_streamer: nil,
  shadow_programs: [
    enabled: false,
    provider: OpenAgents.ShadowPrograms.OpenAI,
    timeout_ms: 5_000
  ],
  voice_call_provider: OpenAgents.Voice.OpenAI.CallClient,
  voice_sideband_provider: OpenAgents.Voice.OpenAI.Sideband,
  recall_search_backend: OpenAgents.Memory.LexicalRecall,
  semantic_index: [
    enabled: false,
    provider: OpenAgents.Memory.OpenAIEmbeddings,
    model_id: "text-embedding-3-small",
    model_version: "2024-01",
    dimensions: 64,
    batch_size: 10,
    poll_interval_ms: 2_000,
    provider_timeout_ms: 15_000,
    lease_ms: 30_000
  ],
  experience_memory: [
    enabled: false,
    maximum_records: 6,
    maximum_patterns: 3,
    maximum_bytes: 4_000
  ],
  graph_memory: [
    enabled: false,
    maximum_nodes: 50,
    maximum_depth: 3,
    maximum_export_artifacts: 500
  ],
  memory_portability: [enabled: false],
  # Cloud memories (`OpenAgents.Memories`). `embeddings_enabled` chooses the
  # target retrieval backend; with it off, recall runs on the lexical stand-in.
  # The three bounds are the store's ceiling per account and the per-turn
  # ceilings on how much memory may reach the model.
  #
  # `system_bucket_enabled` is the cross-account switch, and it is off here and
  # in production. With it off, recall reads the `user` and `learned` buckets
  # and nothing else, exactly as it did before the system bucket existed. With
  # it on, an admitted system memory written by one account reaches every
  # account's turn under the eligibility filter and the caps in
  # `OpenAgents.Memories.SystemRecall` (MEMORY-001, MEMORY-011).
  # `maximum_system_pool` bounds the ranked candidate pool the per-source cap
  # is a share of.
  memory_recall: [
    embeddings_enabled: false,
    system_bucket_enabled: false,
    maximum_system_pool: 40,
    provider: OpenAgents.Memory.OpenAIEmbeddings,
    model_id: "text-embedding-3-small",
    model_version: "2024-01",
    dimensions: 64,
    floor: 0.3,
    maximum_live_memories: 200,
    maximum_attached: 8,
    maximum_attached_characters: 2_000
  ],
  tool_discovery: [
    embeddings_enabled: false,
    provider: OpenAgents.Memory.OpenAIEmbeddings,
    model_id: "text-embedding-3-small",
    model_version: "2024-01",
    dimensions: 64,
    top_k: 12
  ],
  plugin_discovery: [
    embeddings_enabled: false,
    provider: OpenAgents.Memory.OpenAIEmbeddings,
    model_id: "text-embedding-3-small",
    model_version: "2024-01",
    dimensions: 64,
    top_k: 12
  ],
  # The shipped tool catalog. It is a zero base, deliberately small.
  #
  # A tool ships only when it meets every admission criterion in
  # `docs/2026-08-23-agent-tools-zero-base.md`, section 6. The short form:
  # it is read-only, it works for every caller that can see it, its refusal
  # path has a test, its description says when not to use it, and it earns
  # its place in the prompt budget. `test/openagents/tools/shipped_catalog_test.exs`
  # enforces the mechanical half; the rest is review.
  #
  # Every other tool module stays in `lib/openagents/tools/` and stays under
  # test through the broader fixture catalog in `config/test.exs`. Unregistered
  # is not deleted. Adding a module back here is a policy change, not a
  # one-line edit.
  tools: [
    # Discovery. The escape hatch that lets the model see the catalog it was
    # given. Needs only the captured registry snapshot, so it never refuses.
    OpenAgents.Tools.ModuleDiscover,

    # Read this application's own source. `from: "image"` resolves to the
    # baked source root and needs no owner, workspace, or job, so these
    # answer "what does your code do" for every caller.
    OpenAgents.Tools.RepoRead,
    OpenAgents.Tools.RepoGrep,
    OpenAgents.Tools.RepoList,

    # Read a forge repository the signed-in person can see. These resolve
    # through `owner_user_id`, the one owner field every conversation caller
    # populates correctly today.
    OpenAgents.Tools.ConnectedRepositoryRead,
    OpenAgents.Tools.ConnectedRepositoryList,

    # File a request the person made in chat as an issue, in a repository they
    # can write to (#77). The one tool here that is not read-only, and the
    # reason TOOL-006 now says "read-only, or gated on a current consent
    # receipt" rather than "read-only". It declares `:external_effect` with
    # `approval_enforcement: "host_receipt"`, so `SurfacePolicy` demands an
    # explicit, current, person-signed receipt bound to this module, version,
    # and conversation — checked when the catalog is built as well as when the
    # tool runs. On a turn with no such receipt it is not offered, so it costs
    # nothing and never trains anyone to ignore a refusal. Authority stays the
    # caller's: it files under their own repository membership.
    OpenAgents.Tools.IssueCapture
  ],
  conversation_reset_enabled: false,
  github_api: [
    base_url: "https://api.github.com",
    request_options: []
  ],
  github_oauth_scopes: ["user:email"],
  voice_recording: [
    enabled: false,
    timeslice_ms: 5_000,
    maximum_chunk_bytes: 1_048_576,
    maximum_chunks: 1_024,
    maximum_bytes: 25_165_824,
    late_chunk_grace_seconds: 120,
    retention_days: 30
  ],
  leaderboard_limit: 100,
  leaderboard_refresh_interval_ms: 1_000,
  leaderboard_auto_refresh_enabled: true,
  voice_recovery_worker_enabled: false,
  voice_retention_worker_enabled: false,
  turn_recovery_enabled: false,
  voice_retention_enabled: false,
  deployment_control_plane_enabled: false,
  ra_enabled: false,
  ra_data_dir: "/var/lib/openagents/ra",
  ra_expected_size: 3,
  horde_enabled: true,
  distribution: [
    enabled: false,
    node_configured: false,
    cookie_configured: false,
    port_min: 9_100,
    port_max: 9_115
  ],
  incident_fixer_enabled: false,
  github_oauth: [
    client_id: nil,
    client_secret: nil,
    redirect_uri: "http://127.0.0.1:4000/auth/github/callback",
    attempt_ttl_seconds: 600,
    request_options: []
  ],
  github_token_encryption_key: nil,
  github_token_encryption_key_id: nil,
  github_token_decryption_keys: %{},
  machine_token_encryption_key: nil,
  voice_recording_encryption_key: nil,
  content_encryption_key: nil,
  inference_proxy_url: nil,
  inference_grant_max_total_tokens: 2_000_000,
  inference_grant_max_calls: 64,
  inference_grant_max_cost_microusd: 5_000_000,
  inference_grant_ttl_seconds: 900,
  # A thread's budget is not a delegation's budget, so it does not borrow one.
  # A delegation is a single bounded probe run the server admitted before it
  # minted anything; a thread is authority handed to a caller's own terminal on
  # request, and it lives as long as someone is working. It therefore gets more
  # calls and a longer life, and a lower token and cost ceiling, because
  # nothing on the server bounded the work first.
  #
  # No cap. A coder session no longer revokes its thread when it exits — a
  # thread is durable and `--resume` is the whole point of that — so a count of
  # open threads is a count of every session the account has ever run, and
  # refusing the ninth would refuse the work rather than bound it. What bounds
  # an account is its credit.
  maximum_open_threads_per_account: nil,
  # Unbounded, all three. A coding session reached 256 calls, a million tokens,
  # and two dollars in an afternoon, and was told to start a new session — the
  # same interruption the clock below used to cause, by another route. A
  # thread is bounded by revocation and by the account's credit.
  thread_grant_max_total_tokens: nil,
  thread_grant_max_calls: nil,
  thread_grant_max_cost_microusd: nil,
  # No clock on a thread's authority. It expiring on a wall clock ended a
  # coding session mid-sentence and told the reader to start a new one, when
  # nothing had gone wrong except that an hour had passed. Budget and
  # revocation bound a thread; time does not.
  thread_grant_ttl_seconds: nil,
  # The inference credit an account draws its threads against. Signing in is
  # what raises it: a visitor holding only a browser key gets the same figure a
  # single thread used to get, and an account with a user behind it gets $20 to
  # spend across every thread it opens.
  #
  # This figure is the default a *new* account is created with, not the
  # allowance every account holds. The allowance lives on `users` as
  # `credit_allowance_microusd`, so lowering this number re-prices the next
  # signup rather than every account that already exists — the accounts created
  # while it read $100 still hold $100. `OpenAgents.Inference.Credit` reads the
  # column; only account creation reads this.
  account_credit_microusd: 20_000_000,
  visitor_credit_microusd: 2_000_000,
  forge_enabled: false,
  forge_boot_converge_enabled: false,
  forge_deploy_lane_enabled: false,
  forge_data_dir: "/var/lib/openagents/forge",
  forge_build_dir: "/var/lib/openagents/workspace/build",
  forge_build_queue_dir: "/var/lib/openagents/workspace/build-queue",
  forge_artifact_dir: "/var/lib/openagents/artifacts",
  forge_build_timeout_ms: 300_000,
  forge_build_output_retention_ms: 604_800_000,
  forge_deploy_timeout_ms: 15_000,
  forge_deploy_token_ttl_ms: 120_000,
  forge_boot_retry_min_ms: 1_000,
  forge_boot_retry_max_ms: 30_000,
  forge_artifact_store: :local,
  forge_build_executor: OpenAgents.Forge.BuildExecutor.Sidecar,
  forge_expected_fleet_size: 1,
  forge_repos: ["openagents.com"],
  forge_internal_git_url: "http://127.0.0.1:8080/OpenAgentsInc",
  forge_operator_token: nil,
  forge_mirror_urls: %{},
  forge_wal_adapter: OpenAgents.Forge.WAL.Local,
  # The published WAL anchor (EXIT-005, ADR 0008). The interval is also the
  # anchor's exposure window: everything pushed after the last anchor is
  # unanchored until the next one.
  forge_wal_anchor_enabled: true,
  forge_wal_anchor_interval_ms: 3_600_000,
  forge_wal_dir: nil,
  forge_wal_bucket: nil,
  # The bucket behind `/releases/<name>`. It grants every object to `allUsers`,
  # so the proxy needs no credential — the name is deployment policy, not a
  # secret, and it has a default so a development server serves the same
  # artifacts production does.
  releases_bucket: "openagentsgemini-cli-releases",
  # The seam `test/openagents_web/controllers/release_controller_test.exs`
  # stubs. Empty everywhere else, so the request options the controller builds
  # are the ones that reach the bucket.
  releases_request_options: [],
  forge_gcs_token_provider: nil,
  forge_rolling_provider: nil,
  # Hot-load allowlist: MODULE names, not repo paths. An entry ending in `.`
  # is a prefix; any other entry is an exact module name (see
  # `OpenAgents.Forge.HotLoader.allowlisted?/2`). The narrow list was never
  # the safety — the canary node + smoke check + revert is — so the code-only
  # web layer ships in seconds instead of a ~25 minute rolling replace.
  forge_hot_load_allowlist: [
    "OpenAgentsWeb.",
    "OpenAgents.Forge.Browse",
    "OpenAgents.Forge.MirrorWatch",
    "OpenAgents.Changelog",
    "OpenAgents.Scratch.",
    "OpenAgents.BuildInfo"
  ],
  forge_hot_load_examples: %{
    "OpenAgentsWeb.ChatLive" => true,
    "OpenAgents.Accounts" => false
  },
  forge_public_visibility: %{"openagents.com" => :l3},
  forge_repo_owners: %{"openagents.com" => "OpenAgentsInc"},
  forge_public_paths: %{"openagents.com" => []},
  dns_cluster_query: nil

# Configure the endpoint
config :openagents, OpenAgentsWeb.Endpoint,
  url: [host: "localhost"],
  adapter: Bandit.PhoenixAdapter,
  render_errors: [
    formats: [html: OpenAgentsWeb.ErrorHTML, json: OpenAgentsWeb.ErrorJSON],
    layout: false
  ],
  pubsub_server: OpenAgents.PubSub,
  live_view: [signing_salt: "VjtdwdCq"]

# Configure LiveView
config :phoenix_live_view,
  # the attribute set on all root tags. Used for Phoenix.LiveView.ColocatedCSS.
  root_tag_attribute: "phx-r"

# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :openagents, OpenAgents.Mailer, adapter: Swoosh.Adapters.Local

# The notification email channel (#141).
#
# `deliverable` is what the settings surface asks before it offers to take an
# address, and it is set here rather than inferred from the Swoosh adapter,
# because the adapter answers the question wrongly in both directions: the
# local adapter is real delivery in development, where the message lands in the
# mailbox preview at "/dev/mailbox", and it is a black hole in production.
# `config/prod.exs` turns it off, and `config/runtime.exs` turns it back on for
# a deployment that configured a provider.
config :openagents, OpenAgents.Notifications.EmailChannel,
  from: {"OpenAgents", "notifications@openagents.com"},
  deliverable: true

config :openagents, OpenAgents.Notifications.Delivery,
  adapter: OpenAgents.Notifications.Delivery.MailerAdapter

# Product analytics (docs/2026-08-21-posthog-integration-runbook.md). The
# default supervisor stays off; OpenAgents.Application starts PostHog.Supervisor
# only when a project token was configured at boot. Error tracking is a
# separate, unapproved decision, so exception capture is off.
config :posthog,
  enable: false,
  api_host: "https://us.i.posthog.com",
  api_key: nil,
  in_app_otp_apps: [:openagents],
  enable_error_tracking: false

# Configure esbuild (the version is required)
config :esbuild,
  version: "0.25.4",
  openagents: [
    args:
      ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
  ]

# Configure tailwind (the version is required)
config :tailwind,
  version: "4.3.0",
  openagents: [
    args: ~w(
      --input=assets/css/app.css
      --output=priv/static/assets/css/app.css
    ),
    cd: Path.expand("..", __DIR__),
    env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
  ]

# Configure Elixir's Logger
config :logger, :default_formatter,
  format: "$time $metadata[$level] $message\n",
  metadata: [:request_id]

# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

# Forum tips are off until an operator admits a self-custodial payment service.
# Tipping stays unavailable rather than routing sats through hosted custody.
config :openagents, :forum_tips,
  enabled: false,
  adapter: OpenAgents.Forum.Tips.PaymentService.Unavailable

config :openagents, OpenAgents.Capacity,
  evidence_source: OpenAgents.Capacity.Broker,
  broker_url: nil,
  broker_token: nil,
  broker_timeout_ms: 2_000,
  maximum_evidence_age_seconds: 120,
  reserved_headroom_fraction: 0.25,
  class_ceilings: %{"standard" => 16, "strong" => 2, "batch" => 8},
  active_per_conversation: 4,
  logical_per_conversation: 30,
  unit_cost_usd_cents_per_hour: %{
    "standard" => 16,
    "strong" => 32,
    "batch" => 8,
    "connected" => 0
  },
  buyer: nil

# Continual learning is off until an operator admits the named buyer, the base
# models, and the exact training code digest. The lane refuses rather than
# training on data or a model nobody admitted.
config :openagents, OpenAgents.ContinualLearning,
  enabled: false,
  buyer_ref: nil,
  buyer_class: "openagents_training",
  runtime_classes: ["standard", "strong"],
  admitted_base_models: %{},
  admitted_custody: ["openagents_managed"],
  maximum_rounds: 8,
  maximum_datasets: 4,
  wall_clock_ms: 900_000,
  maximum_state_bytes: 65_536,
  concurrency_limit: 1,
  training_code_digest: nil,
  trainer: OpenAgents.ContinualLearning.Trainer.Reference,
  evaluator: OpenAgents.ContinualLearning.Evaluator.Reference,
  class_watts: %{"standard" => 350, "strong" => 700, "batch" => 250},
  round_cost_usd_cents: %{"standard" => 2, "strong" => 4, "batch" => 1},
  settlement_unit: "usd_cents",
  outcome_repository: "OpenAgentsInc/openagents.com",
  outcome_issue_number: 86

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"