Skip to content

feat: Custom Model Endpoint Profiles (local or cloud, all harnesses) - #393

Open
opticon454 wants to merge 4 commits into
Ark0N:masterfrom
opticon454:feature-custom-llm-server-support
Open

feat: Custom Model Endpoint Profiles (local or cloud, all harnesses)#393
opticon454 wants to merge 4 commits into
Ark0N:masterfrom
opticon454:feature-custom-llm-server-support

Conversation

@opticon454

@opticon454 opticon454 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

⭐ Shout-out up front: this feature was partly inspired by — and is a
great fit for — Ark0N/Qwen5090,
the maintainer's other project: a one-click Windows / one-command Linux
installer that stands up Qwen3.8-27B locally on an RTX 5090 behind an
OpenAI-compatible API (vLLM / NInfer / llama.cpp). Once this feature lands,
pointing Codeman at a Qwen5090 box is just adding one endpoint entry — no
extra code, no special-casing. Qwen5090 already wires up DeepSeek Harness
and Claude Code as local coding agents itself, which is basically this
feature's idea in miniature. 🙂

Status: open for review. The backend/CLI-injection side (chunks 1-5,
7-8) is built, tested, and verified end-to-end against a real server;
chunk 6 (frontend toolbar UI) is not yet built, and gemini/deepseek remain
documented, unresolved gaps rather than working paths — see
Status below for exactly what's done and what's still open.


What

Adds a settings-gated (default OFF) way to point any Codeman-supported
harness — Claude, opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP, or
Antigravity — at a custom OpenAI-compatible endpoint instead of its
native cloud backend, for a given session. "Custom endpoint" covers both:

  • Local hardware: llama.cpp, Ollama, vLLM, a home GPU rig, or
    purpose-built on-prem boxes like NVIDIA DGX Spark, AMD Strix Halo
    (Ryzen AI Max) mini-PCs, or the Qwen5090
    setup above.
  • Cloud: Azure AI Foundry's OpenAI-compatible endpoint, OpenRouter, a
    company's self-hosted gateway.

The user adds an endpoint by base URL (+ optional API key), Codeman
discovers its available models via GET /v1/models, and a new toolbar
picker lets them apply one of those models to a session — which then
restarts that session's CLI process pointed at the endpoint.

Why discovery instead of asking the user to type a model name: it turns
"go read your inference server's docs to find the exact model identifier it
expects" into "pick from a list Codeman already fetched" — one less place
for a user to get a name/casing wrong and have a harness fail with an
opaque "model not found." It also means this feature works unmodified
against multi-model hosting setups, not just a single-model server: a
gateway like llama-swap
(or vLLM/LiteLLM/Ollama serving several loaded/loadable models behind one
/v1/models list) already advertises every model it can hot-swap to, so
the toolbar picker becomes a live menu of everything that endpoint can
serve — no per-model endpoint entries, no separate configuration step,
just "add the gateway once, everything behind it shows up."

Why

The maintainer pays for a Claude Code subscription but also runs a capable
local model. Every harness Codeman drives already has its own mechanism
for pointing at a custom endpoint (env vars for Claude, a JSON config blob
for opencode, a TOML file for Codex, etc.) — Codeman just never exposed a
UI for it. Full motivation, the per-CLI recipe table, and the on-prem
hardware use cases are written up in deployment_plan.md.

How

  • src/config/cli-registry/{types,schema,stock}.ts — new
    capabilities.customModelInjection field per CLI entry, one of four
    kinds: env (Claude, Gemini, Grok, DeepSeek), configContentEnv
    (opencode, reusing its existing OPENCODE_CONFIG_CONTENT mechanism),
    configDir (Codex/Pi/OMP — writes an isolated config file, never touches
    the user's real one), or unsupported (Antigravity — no known mechanism,
    toolbar entry stays disabled). Declared data-driven per the repo's
    existing "never branch on CLI id" rule.
  • src/custom-model-injection.ts — pure function turning
    (CliEntry, endpoint, modelId) into the real env vars / config content.
    No IO; a caller writes configDir files to disk.
  • src/custom-model-hosts.ts + src/web/routes/custom-model-routes.ts
    read/write-array endpoint store (~/.codeman/custom-model-hosts.json,
    same shape as remote-hosts.ts) and GET/POST/PUT/DELETE /api/model-endpoints + POST /:id/discover-models, admin-gated in
    multi-user mode, SSRF-guarded via the same isBlockedWebviewUrl() check
    web tabs use.
  • src/web/schemas.tscustomModelEndpointsEnabled (synced, default
    OFF) + the endpoint payload schema.
  • scripts/test-local-llm-harnesses.ts — standalone smoke-test script
    that spawns each real CLI binary one-shot against a real endpoint and
    checks it can answer "hello world," independent of the web UI. Reads
    defaults from a gitignored scripts/local-llm-test.config.json (see the
    committed .example.json) so real IPs/keys never land in git.

A finding along the way: multi-user privilege hardening

Building this surfaced that several env vars (GOOGLE_GEMINI_BASE_URL,
GROK_BASE_URL, CODEX_HOME, PI_CONFIG_DIR, OPENCODE_CONFIG_CONTENT,
etc.) were already reachable via the generic envOverrides API today,
pre-existing this PR, because Codeman's env allowlist is prefix-based and
global. A non-granted multi-user owner could already redirect a session's
endpoint/credentials via a plain envOverrides field. This PR adds all of
them to their CLI's privilegedEnvKeys (the existing clamp mechanism
DEEPSEEK_BASE_URL already used), closing that gap rather than widening it.
CODEX_HOME and PI_CONFIG_DIR are flagged as extra-sensitive: a
redirected config dir can restate approval/sandbox policy or, for Pi,
redirect to a dir Pi will execute .pi/extensions TypeScript from.

Claude is the deliberate exception: ANTHROPIC_* is not added to
Claude's allowed env prefixes at all, so it stays reachable only through
the dedicated, admin-configured, SSRF-guarded custom-model route — never
through a plain client-supplied envOverrides.

Status

Built in reviewable chunks; ✅ = done and verified (typecheck + lint +
format + tests green), ⬜ = not started.

  • 1. Registry typescustomModelInjection capability shape
  • 2. Pure injection buildercustom-model-injection.ts + 15 unit tests
  • 3. Endpoint store + CRUD routescustom-model-hosts.ts,
    custom-model-routes.ts, discovery + SSRF guard, 7 route tests
  • 4. Settings + security hardeningcustomModelEndpointsEnabled
    flag, privilegedEnvKeys additions across 7 CLI entries
  • 5. Session integrationsession.customModel state field,
    session.setCustomModel()/session.restartCli() (a generalized,
    de-restricted reattachRemote() reusing the existing respawn-pane -k
    primitive), POST /api/sessions/:id/custom-model restart route. 5 new
    route tests; the existing session.test.ts/session-cleanup.test.ts
    suites can't run at all on this Windows dev box (no local tmux
    confirmed identical on unmodified master, not a regression), which is
    exactly why the container test below matters.
  • 6. Frontend — settings group, toolbar picker, tab badge
  • 7. Mock-server contract teststest/fixtures/mock-openai-server.ts
    and test/custom-model-injection-contract.test.ts, 10 tests replaying
    every CLI's real injected values through an HTTP call shaped the way that
    CLI sends it, against an in-process fake server
  • 8. Docsdocs/custom-model-endpoints.md (user guide, HTTP-API-only
    until chunk 6 lands) + a CLAUDE.md pointer bullet

Also done outside the chunk list: the standalone
scripts/test-local-llm-harnesses.ts smoke-test script (now dynamic —
reads the live CLI registry rather than a hand-maintained harness list) +
its gitignored config file, the on-prem-hardware use-case writeup in
deployment_plan.md (DGX Spark, Strix Halo, Qwen5090), a
codeman/agent:llm-test Docker image (all 9 CLI binaries, built from
docker/agent.Dockerfile), and a completed real end-to-end run of all 9
harnesses
against a live llama-swap server — see Testing below.

Testing performed so far

  • npm run typecheck — clean after every chunk
  • npm run lint / npx prettier --check — clean
  • npm test -- test/cli-registry test/custom-model-injection.test.ts test/custom-model-injection-contract.test.ts test/routes/custom-model-routes.test.ts test/routes/session-custom-model.test.ts test/routes/external-cli-bypass-clamp.test.ts
    245+ tests passing, including the existing multi-user clamp suite (no
    regressions from the privilegedEnvKeys additions)
  • Refactored scripts/test-local-llm-harnesses.ts (now npx tsx-run, was
    plain .mjs) to import enabledClis() and buildCustomModelInjection()
    directly from source instead of keeping a second hand-maintained copy of
    every CLI's env/config shape — a registry change now needs zero edits to
    the test script. Extracted the config-dir-write logic shared with the
    production route into custom-model-injection-apply.ts so both places
    call exactly one implementation.
  • Real end-to-end run against the maintainer's live llama-swap server
    (http://10.10.11.241:8080), inside codeman/agent:llm-test (all 9 CLI
    binaries, built via docker/agent.Dockerfile), against the smallest
    available model (qwen3.5-0.8b-ud-q8_k_xl, 1.1GB — picked by parsing the
    server's own reported model sizes). Full 9-harness result: claude,
    opencode, pi, grok, omp all PASS with a genuine "hello world" reply
    round-tripped through the real endpoint; codex FAILs for a confirmed
    protocol reason (not a bug — see below); gemini and deepseek reach the
    server but fail for reasons not yet root-caused; antigravity SKIPs (no
    known mechanism); all correctly classified by the now-dynamic
    scripts/test-local-llm-harnesses.ts, which reads the live CLI registry
    rather than a hand-maintained harness list.
    Real findings, not
    simulated:
    • opencode: PASS. Genuinely round-tripped a "hello world" reply
      through the real endpoint.
    • pi: PASS, after two real bugs found and fixed. PI_CONFIG_DIR does
      nothing for pi at all (grepped pi's entire bundled JS source — the
      string appears nowhere); the real redirect is the child process's own
      HOME, since pi hardcodes ~/.pi/agent/models.json with no dedicated
      override. Separately, pi's models field must be an array of
      {id} objects, not an object keyed by id (confirmed against pi's own
      bundled docs/models.md) — the object shape silently loaded zero
      models. Also needs an explicit --model custom/<id> on invocation.
    • grok: PASS, after the original recipe turned out to be flat-out
      wrong
      , not just unverified — the env-var recipe in this table's first
      draft (GROK_BASE_URL/XAI_API_KEY/GROK_MODEL) produced "Not signed
      in" against a real binary. Researched xAI's actual docs and corrected
      to a config.toml with a [model.<name>] block redirected via
      GROK_HOME, with the key riding as an env_key-named env var — then
      confirmed working end-to-end.
    • omp: PASS, after the same two fixes as pi (array-shaped models,
      HOME-redirect instead of PI_CONFIG_DIR) plus --model custom/<id>.
      Unverified against omp's own official docs (none are bundled in the
      install), but empirically confirmed working live.
    • gemini: confirmed broken, unresolved after real investigation.
      Setting GOOGLE_GEMINI_BASE_URL makes gemini-cli internally select an
      undocumented AuthType.GATEWAY path with validation requirements a
      live run never satisfies (Invalid auth method selected, regardless of
      key format). Tried and ruled out: a Google-format dummy key,
      GOOGLE_GENAI_USE_VERTEXAI=false, a GEMINI_DEFAULT_AUTH_TYPE
      override, and a hand-written settings.json. --skip-trust is a real,
      separate fix for a different symptom (an untrusted-folder check
      silently overriding --approval-mode yolo) and is kept, but does not
      touch this auth failure. Left as an open, documented gap rather than
      claimed as working.
    • deepseek: confirmed reaching the server, still failing, unresolved.
      A real run returns dsh: HTTP_404: DeepSeek API error (HTTP 404)
      consistently — the env vars are read (the request reaches the network
      rather than failing locally), but the root cause was not identified in
      the time available. By analogy with codex's Responses-API gap, dsh
      may expect DeepSeek's own API response shape rather than a generic
      OpenAI-compatible one, but this was not confirmed by reading dsh's own
      bundled source the way the pi/grok questions were resolved. Documented
      as best-effort/unknown, matching its pre-existing lowest confidence tag.
    • codex: real bug found and fixed. The recipe's TOML shape
      ([model].default) was rejected by a real codex binary ("invalid
      type: map, expected a string") — codex wants a top-level model
      string plus [model_providers.custom], and the API key rides as an
      env_key-named env var, never a literal TOML field. Fixed in
      custom-model-injection.ts, the standalone script, and both test
      suites. Then a second, deeper finding: codex only speaks the
      Responses API now (wire_api = "responses", the only value it accepts
      since dropping "chat" support in Feb 2026) — a real run against the
      now-correctly-shaped config still failed (Reconnecting... × 5, then
      "high demand" errors) because llama-swap doesn't implement
      /v1/responses. This is a genuine, currently-unresolved protocol
      incompatibility, not a bug in this PR's code — documented prominently
      in deployment_plan.md's confidence table.
    • claude: PASS, after two real bugs found and fixed. (1) Claude
      Code's async session-title-generation call also uses
      ANTHROPIC_DEFAULT_HAIKU_MODEL and validates it against Claude's own
      internal recognized-model list, printing [claude-code:unrecognized_model]
      and, in -p mode, hanging the whole invocation rather than just
      warning. --settings '{"autoTitle":false}' does NOT
      stop it (confirmed); --bare does — the warning still prints, but the
      real prompt now runs and returns the real answer. ⚠️ --bare is only
      safe for this standalone one-shot test script — it also disables hooks,
      LSP, plugin sync, and CLAUDE.md auto-discovery, so it must NEVER be
      applied to a real interactive Codeman session (which depends on hooks
      for idle detection, trust-dialog auto-accept, etc.). Whether an
      INTERACTIVE session with a custom model hits the same hang (vs. just a
      background warning, which would be harmless) is untested — flagged as
      an open item for chunk 5/6, not assumed either way. (2) A separate,
      genuinely nasty bug in the test script itself: a POST issued right
      after a GET in the same Node process reliably HUNG indefinitely
      against this real server (reproduced repeatedly: GET alone ~30ms, POST
      alone ~1-2s, GET-then-immediate-POST times out completely; a 2s pause
      between them fixed it every time) — looks like Node's fetch/undici
      reusing a pooled keep-alive connection the server doesn't handle
      cleanly for a second request right behind a first. Fixed with a 2s
      pause between the script's discovery GET and its baseline POST. This
      is a tooling-correctness fix (affects the script's own baseline check),
      not a claim about how any CLI's own HTTP client behaves.
    • Also found and fixed: an earlier design sent BOTH Authorization: Bearer and api-key auth header conventions on every discovery/
      baseline request, on the theory that an unused header is harmless.
      Live-tested against the real server, sending both reliably HUNG the
      request (reproduced 3×: either header alone ~500-600ms, both together
      no response inside 15s). Removed the 'both' option entirely from
      CustomModelAuthStyle (was 'bearer' | 'api-key' | 'both', now just
      the first two, default 'bearer') — in the schema, the store type, the
      discovery route, and the standalone script (--auth-style flag added).
      This was a real, currently-shipped-in-this-PR bug fixed before it ever
      reached anyone, not a pre-existing one.

Not yet done / open questions for review

  • Chunk 6 (frontend) — settings group, toolbar picker, tab badge — is
    still entirely unbuilt; the feature is currently HTTP-API-only (see
    docs/custom-model-endpoints.md).
  • Gemini is confirmed broken end-to-end (Invalid auth method selected, traced to an undocumented GATEWAY AuthType gemini-cli
    selects once GOOGLE_GEMINI_BASE_URL is set) — needs upstream
    investigation before it can be called supported. Documented in full in
    deployment_plan.md's confidence table rather than silently shipped as
    working.
  • DeepSeek is confirmed reaching the server but failing with a
    consistent HTTP_404, root cause not identified — documented as
    best-effort/unknown, same as its pre-existing lowest confidence tag.
  • Codex cannot work against a plain OpenAI-Chat-Completions server
    (llama.cpp/llama-swap/Ollama/vLLM's default) — it only speaks the
    Responses API since Feb 2026. This is an external protocol
    incompatibility, not something this PR can fix; codex support is real
    only against a Responses-API-compatible endpoint.
  • Antigravity has no known mechanism at all and stays unsupported.
  • Chunk 5's session-restart design needs a careful look before merge:
    switching a session's endpoint restarts its CLI process in place
    (confirmed acceptable with the maintainer — these harnesses read
    endpoint config at process start, not per-turn). Whether an INTERACTIVE
    claude session with a custom model hits the same async-title-generation
    hang the standalone script worked around with --bare (vs. just a
    harmless background warning) is untested and should be checked before
    calling claude's chunk 5 support done — --bare itself must never be
    applied to a real interactive session, since it disables hooks Codeman
    depends on.

🤖 Generated with Claude Code

opticon454 and others added 2 commits September 8, 2026 16:36
…ll harnesses)

Point any Codeman-supported harness (Claude, opencode, Codex, Gemini, Pi,
Grok, DeepSeek, OMP) at a custom OpenAI-compatible endpoint instead of its
native cloud backend, for a given session. Covers local hardware (llama.cpp,
Ollama, vLLM, DGX Spark, Strix Halo) and cloud (Azure AI Foundry, OpenRouter).
Off by default (customModelEndpointsEnabled, synced, default OFF).

- Registry: capabilities.customModelInjection per CLI entry (env /
  configContentEnv / configDir / unsupported kinds)
- Pure injection builder (custom-model-injection.ts) turning an endpoint +
  model id into the real env vars / config content per CLI
- Endpoint store + CRUD routes (custom-model-hosts.ts,
  custom-model-routes.ts), discovery via GET /v1/models, SSRF-guarded
- Session integration: Session.setCustomModel()/restartCli()
  (POST /api/sessions/:id/custom-model), reusing the existing
  respawn-pane -k primitive to restart the CLI process with new env
- Multi-user hardening: every new redirect-capable env var added to its
  CLI's privilegedEnvKeys, closing a pre-existing gap where several were
  already reachable via the generic envOverrides field's prefix allowlist
- Standalone scripts/test-local-llm-harnesses.mjs: spawns real CLI binaries
  against a real endpoint outside the web UI, independent of tmux/sessions
- Mock-server contract tests (test/fixtures/mock-openai-server.ts) replaying
  every CLI's injected values through a real HTTP shape

Real end-to-end validation against a live llama-swap server (inside a
codeman/agent:llm-test Docker image with all 9 CLI binaries) found and
fixed three real bugs before they shipped:
- Codex's config.toml schema was wrong ([model].default table instead of
  a top-level model string + [model_providers.custom]); fixing it then
  surfaced a genuine, documented protocol incompatibility (Codex only
  speaks the Responses API since Feb 2026, which llama.cpp/llama-swap
  don't implement)
- Claude Code's async session-title-generation call validates
  ANTHROPIC_DEFAULT_HAIKU_MODEL against its own internal model list and
  hangs the whole -p invocation on an unrecognized name; documented for
  chunk 6, worked around in the standalone script only (--bare is NOT
  safe for a real interactive session, which needs hooks)
- The discovery route's authStyle: 'both' option (send both Authorization
  and api-key headers) reliably hung a real server; removed the option
  entirely rather than just changing the default

Status: draft. Chunk 6 (frontend toolbar/settings UI) not yet built — see
PR.md and deployment_plan.md for the full chunk breakdown and confidence
table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HqNWfmtBU2KN29SvSVWB3
… CLIs end-to-end

Rewrites scripts/test-local-llm-harnesses.mjs -> .ts to read the live CLI
registry (enabledClis()) and call the real production
buildCustomModelInjection()/applyConfigDirInjection() instead of keeping a
second hand-maintained copy of every CLI's env/config shape. A future
registry change (new CLI, edited env var, fixed config template) is now
picked up automatically with zero edits to this script; only the one-shot
invocation flags (info the registry genuinely doesn't model) stay in a
small hand-maintained ONE_SHOT table, and a registry CLI with no entry
there reports UNKNOWN rather than being silently skipped.

Extracted src/custom-model-injection-apply.ts (applyConfigDirInjection/
removeConfigDir) so the production route and this script share one
implementation instead of two.

Full end-to-end run against a real llama-swap server, inside a
codeman/agent:llm-test Docker image with all 9 CLI binaries:

- claude, opencode, pi, grok, omp: PASS, real "hello world" replies
- codex: confirmed FAIL for a real protocol reason, not a bug — it only
  speaks the Responses API since Feb 2026, which llama.cpp/llama-swap
  don't implement
- gemini: confirmed FAIL, unresolved after real investigation — an
  undocumented GATEWAY AuthType gemini-cli selects once
  GOOGLE_GEMINI_BASE_URL is set rejects every auth-key format/override
  tried
- deepseek: reaches the server (env vars are read) but gets a consistent
  HTTP_404; root cause not identified, documented as best-effort/unknown
- antigravity: SKIP, no known mechanism (unchanged)

Two real bugs found and fixed along the way (grok, pi/omp registry
entries in stock.ts): grok's original recipe (env vars) was flat-out
wrong, not just unverified — the real mechanism is a config.toml
[model.<name>] block redirected via GROK_HOME. pi/omp's PI_CONFIG_DIR
does nothing for either (grepped pi's entire bundled source — the string
appears nowhere); the real redirect is the child process's own HOME, and
both need `models` as an array of {id} objects, not an object keyed by
id (silently loaded zero models otherwise).

deployment_plan.md, PR.md, docs/custom-model-endpoints.md, and CLAUDE.md
updated with the final confidence table reflecting all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HqNWfmtBU2KN29SvSVWB3
@opticon454

Copy link
Copy Markdown
Contributor Author

I'm currently away ATM, I'll rejig this more next week when I'm back.

But I'm extremely keen to know your thoughts/comments on how you'd like this to work

@opticon454
opticon454 marked this pull request as ready for review September 8, 2026 09:50
opticon454 and others added 2 commits September 8, 2026 17:51
CI caught a real regression: DEEPSEEK_API_KEY was added to deepseek's
privilegedEnvKeys alongside DEEPSEEK_BASE_URL on the theory that "the pair
travels together," but that contradicts the documented and tested design
(clampEnvOverridesForOwner()'s own docstring in session-routes.ts) — a
non-granted owner supplying their OWN DeepSeek key removes privilege
rather than granting it, since the exfiltration vector is the BASE URL
(which redirects the server's own forwarded key to a foreign host), not
the key itself. Removed it from the list; test/deepseek-mode.test.ts's
existing two clamp tests now pass again.

Also swapped that test's "unrelated override" example off CODEX_HOME,
which the earlier commit in this same PR legitimately made privileged
(closing a real pre-existing gap, documented in PR.md) — so it stopped
being a valid "unrelated" example the moment that fix landed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HqNWfmtBU2KN29SvSVWB3
Ark0N pushed a commit that referenced this pull request Sep 8, 2026
Claude Code answers an exhausted model budget INSIDE the turn ("You've
reached your Fable limit. Run /usage-credits to continue or switch models
with /model.") and then sits there with nothing to write. The reviewer never
produces a report, so `runTurn` waited out its full 40-minute deadline and
reported a bare "timed out after 40 min without a report", which reads as a
hung reviewer rather than an account that needs attention.

Measured on 2026-09-08: #388, #393, #394 and #377 each lost 40 minutes this
way, and because every attempt counted, all four reached MAX_AUTO_RETRIES and
would NOT have been picked up again once the budget returned. One spent
afternoon quietly took the whole queue out of service.

`findModelLimitNotice()` reads the notice off the pane and `runTurn` returns
a new `limit` outcome instead of waiting. It is consulted in exactly two
places, both of which mean "the turn produced nothing": on a stop where
`isDone()` is still false, and on each timed-out wait slice. A review that
merely discusses usage limits in its own findings therefore cannot be
mistaken for one that hit the wall, and the pattern matches neither the model
name nor a straight apostrophe, since the pane renders a typographic one and
every model prints the same sentence.

A spent budget is an account condition, not a bad PR, so it no longer spends
the per-head retry budget: the queue resumes by itself when the budget does.
Telegram now names the cause and the file to change.

Tests use the pane captured verbatim off the run that lost the 40 minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant