14 KiB
Model Access Plans
Model access plans are daemon-owned, per-workspace configurations for routing supported agent runtimes through a named model endpoint. A plan owns its wire protocol, endpoint, encrypted credential, model catalog, default model, detection state, and enabled state.
This slice owns Plans. WorkspaceAgent is the user-facing configuration seam
that combines one Harness AgentTarget with an optional Plan/default model and
instructions. The older per-AgentTarget binding and modelpolicy domains are
compatibility-only: they remain readable for historical sessions and deletion
protection, but Desktop Settings must not expose them as parallel writable
configuration.
Ownership
services/tuttid/biz/modelplanowns the Plan model and validation.services/tuttid/data/workspaceowns Plan persistence and retains legacy binding persistence during the compatibility window.services/tuttid/service/modelplanowns CRUD, staged endpoint detection, and deletion protection.services/tuttid/service/workspaceagentowns every new Harness-to-Plan mapping.services/tuttid/service/modelbindingis a legacy adapter and reference source; Desktop no longer calls its write routes.packages/agent/daemon/providerregistrydeclares whether a provider runtime accepts a model-plan endpoint, which protocol it consumes, and whether it requires the Responses-to-Chat gateway adapter.packages/agent/runtimeprepcontains the provider-specific endpoint adapters. Codex and Tutti Agent receive session-scoped Responses provider configurations pointed at the daemon's loopback Model Gateway; Claude Code receives its supported environment contract; OpenCode receives a session-scopedopencode.jsonprovider block viaOPENCODE_CONFIG(credential travels only asTUTTI_MODEL_PLAN_API_KEYwith an{env:…}reference in the file).services/tuttid/service/modelgatewayowns the localResponses API ↔ Chat Completions APItransport adapter. It does not define session lifecycle, expose a public daemon API, or act as a general multi-provider protocol IR.services/tuttid/service/agentresolves the WorkspaceAgent and supplies its Plan endpoint to runtime preparation. Unsnapshotted historical sessions may still use the isolated legacy-binding fallback.- Desktop settings and AgentGUI composer surfaces consume the daemon APIs directly; they do not own plan credentials or detection state.
Provider support is fail-closed. A provider may advertise
modelPlanBinding only when its registry descriptor declares a protocol and a
matching runtime-preparation adapter exists. OpenCode consumes openai plans
through that path; Cursor currently keeps its provider-native credential
source. Provider catalog identity carries modelPlanProtocol so desktop
resolves protocols through the catalog instead of provider-identity switches.
Request And Runtime Flow
- A client creates a Plan through the OpenAPI-defined daemon route. Saving does not require or trigger detection; the user may explicitly run the connection check on any saved Plan.
- The user explicitly creates a WorkspaceAgent by choosing one Harness and, optionally, one Plan/default model. Saving a Plan never creates the Harness × Plan Cartesian product.
- Before a new session starts, tuttid resolves that WorkspaceAgent and validates its requested model against the Plan catalog.
- Runtime preparation injects the endpoint and credential only into the session-scoped provider environment/configuration. Codex and Tutti Agent receive a temporary gateway token instead of the upstream Plan credential. Credentials are never returned by the API or written into generated instructions and manifests.
Disabling a plan prevents new sessions from using it; existing running
sessions are not interrupted. Deleting a plan is rejected while any consumer
still references it: agent target bindings and model-usage policies both
count. The references API returns agent_target and model_policy entries,
each carrying the consumer's role (bindings report default; policies report
execution, planning, or review). Symmetrically, deleting a model-usage
policy is rejected while any agent binding still references it; rebind or clear
those bindings first.
Product Availability
Model Plan reads and writes are generally available and do not depend on
desktop preferences. Desktop always exposes Plans in an independent top-level
Model tab and Custom Agents under Agent. Historical lab.modelPlans and
lab.workspaceAgents preference values are ignored.
Credential Ownership
- Plan credentials are AES-256-GCM encrypted at rest in
model_plans(api_key_ciphertext), sharing the managed-credential key derivation. - API responses expose only
hasApiKey. For Responses-gateway consumers (currently Codex and Tutti Agent), the upstream credential remains in daemon memory and only a random 256-bit gateway token entersTUTTI_MODEL_PLAN_API_KEY. Other supported runtimes continue to receive their provider-specific session environment, and the legacy workspace-app grant broker remains a separate credential egress. - Credentials must never appear in logs, events, timeline payloads, detection
results, or generated provider instructions.
runtimeprepwrites the Codex-compatible provider table withenv_key, never the key value. The gateway also redacts the exact upstream credential from forwarded error bodies.
Staged Detection
modelplan.Service.Detect runs four daemon-verifiable stages in order —
network → auth → model_discovery → inference — each with a machine-readable
failureReason/remedy code. A Plan becomes ready when all four stages pass
or are explicitly skipped. Saving a new or changed Plan never runs this check
and never blocks on its result: a new Plan starts undetected, and connection
changes reset it to undetected until the user runs the saved-row check.
Providers without a /models catalog (404 on every candidate) keep working:
discovery is skipped when manual models exist and the inference stage
becomes the authoritative credential check.
Provider discovery is a candidate catalog, not a user selection. When an
endpoint-backed draft has no selected model but discovery returns candidates,
detection may use the first candidate only as the ephemeral inference target
for that run. The tested id is recorded in the detection snapshot, while the
Plan's models and defaultModel remain unchanged until the user selects one.
official_subscription uses a different adapter behind the same stages. The
provider registry chooses the one native runtime for each protocol (Codex for
OpenAI and Claude Code for Anthropic). Detection checks installation/runtime,
the provider's existing login, provider-native model discovery, and one hidden
minimal inference call. It never accepts a Base URL/API key or returns native
credentials. Endpoint-backed templates continue to use the HTTP detection
path.
Runtime Injection Chain
WorkspaceAgent primary Plan/default model
-> agent.Service.resolveModelPlanEndpoint (Create + prepareRuntime)
-> runtimeprep.PrepareInput.ModelEndpoint
-> Codex / Tutti Agent:
agent.Service registers workspace/session route in loopback Model Gateway
-> provider preparer writes local /v1 + temporary env_key + wire_api=responses
-> gateway translates POST /v1/responses to upstream /v1/chat/completions
ClaudeCodePreparer: ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN
OpenCodePreparer: session opencode.json provider block via OPENCODE_CONFIG
-> provider runtime speaks to the plan endpoint for the whole session
Rules:
- Protocol compatibility is explicit:
codex/tutti-agent/opencodeconsumeopenaiplans,claude-codeconsumesanthropicplans. Cursor keeps provider-native credentials (no endpoint injection) — no fake UI entry points. - Anthropic-protocol credential injection follows the endpoint's Claude Code
contract, not a blanket “official versus relay” rule.
api.anthropic.comand Kimi Coding (api.kimi.com) requireANTHROPIC_API_KEY/x-api-key; other relay-style endpoints keep the bearerANTHROPIC_AUTH_TOKENdefault. Runtime preparation explicitly blanks the opposite credential variable so inherited shell or Claude settings cannot override the Plan's auth shape. Detection and the launched Agent must use equivalent authentication shapes, or a plan can pass inference detection while every real Agent turn gets 401. - Model addressing is a second registry strategy
(
ModelPlanModelAddressing): OpenCode declaresprovider_prefixed, so its composer/settings values carry the injectedtutti-model-plan/<model>namespace resolved against the session-scoped provider block. Other providers consume raw plan model ids; validation strips the namespace back to plan-domain ids. - Disabled or protocol-mismatched plans fall back to the provider-native credential source with a structured log, never a broken session.
- A plan-bound session validates requested models against the plan's model
list (
validateModelAgainstPlan), not provider catalogs. - Responses-gateway routes are in-memory runtime resources keyed by workspace/session. Create failure, deletion, legacy cleanup paths, and resume replacement revoke the previous token. Before each new ordinary Turn, a plan-bound Session compares its snapshotted revision with the current revision of that exact Plan. A change prepares a replacement runtime from the new immutable revision, atomically swaps the durable snapshot, and only then admits the Turn. Guidance and an already-running Turn keep their existing binding.
- The Responses-to-Chat gateway is bound to
127.0.0.1:0on its own listener and serves only authenticatedPOST /v1/responses. It is not mounted on the public tuttid HTTP router, so no daemon OpenAPI change is involved. - Gateway v1 supports messages, text/image input, function calls and outputs,
function tools (including Codex namespace containers, with collision-safe
Chat names restored on Responses output), reasoning effort/content
extensions, Chat JSON/SSE, usage, cancellation, and bounded timeouts.
Responses roles follow the Codex-to-Chat normalization used by cc-switch:
developerbecomessystem,latest_reminderand unknown internal roles becomeuser, text-only content-part arrays are joined with newlines, and all textual system instructions are merged in original order into the first Chat message. This preserves instruction precedence while supporting upstreams that only tokenize the portable role set or only allowsystemat message index zero. At the request boundary, the gateway intersects tool registrations with this translatable set: non-translatable hosted or future tool declarations are omitted before forwarding to Chat. If filtering leaves no tools, it also omitstool_choiceandparallel_tool_calls. Explicit selection of a filtered tool,requiredwith no remaining tool, and hosted tool call/output history remain hard errors because dropping them would change requested or already-recorded conversation semantics. Background Responses, Conversations, Realtime/WebSocket,/responses/compact, and non-emptyprevious_response_idare also rejected instead of dropped. - Composer options for a bound target replace provider-native model options
with the plan's models (
applyModelPlanComposerOverlay); each option carries the source plan name andruntimeContext.modelPlan = {id, name, protocol}.
Model Switch Semantics
- Within one plan (same credential/base URL): the model change applies to the
next call of the same session —
modelSwitchcapability, existing session settings update path. - Across plans or credential sources: always a new session (the endpoint is fixed at session start). UI must present this as "will apply in a new session" and reuse the existing handoff/draft-prefill launch path; there is no pretend hot switch.
- Configuration changes never touch running calls; they affect the next call that has not started.
Acceptance Ladder And The Fixed Review Rule
modelpolicy keeps a per-session acceptance ladder:
agent_claimed → auto_checked → user_accepted. Only an explicit user action
reaches user_accepted today. Automated review runs and automated ladder
advancement remain deferred; do not document or UI-promise those behaviors
until the later stack layer lands. Review runs must never close work without
the user.
Explicit in-composer “model consult” (ask another plan/model for advice from
the current session) is not a product surface: do not ship composer entry
points, runtime commands, timeline cards, or @ mentions for it. Model access
plans remain configurable through workspace settings and Agent/plan assignment
surfaces; the composer does not select models through its @ panel.
Migration
model_plans_v1 backfills every legacy managed_model_provider_credentials
row into a named plan (mp-migrated-<provider>), copying the ciphertext
as-is. The WorkspaceAgent migration deterministically materializes legacy
AgentTarget bindings as source=legacy_binding Agents. Legacy binding rows
stay for rollback and pre-snapshot session recovery; new renderer writes are
forbidden and the migration must remain idempotent.
The retired first_use_json column and
model_plan_first_use_candidates_v1 table migration remain in the SQLite
schema for downgrade compatibility. Current code does not expose, write, or
reconcile first-use state.
Reference Protection
DELETE /v1/workspaces/{id}/model-plans/{planId} is blocked (409
model_plan_referenced) while WorkspaceAgents or legacy consumers reference the plan;
GET .../references lists the consumers so the UI shows impact before edits.
The resolver composes per-consumer sources (modelbinding.Service,
modelpolicy.Service, and workspaceagent.Service). Symmetrically,
DELETE /v1/workspaces/{id}/model-policies/{policyId} is blocked (409
model_policy_referenced) while any agent binding still references the
policy.
What To Avoid
- Do not add a "global current model" or a second AgentTarget binding editor. New model selection is owned by explicit WorkspaceAgents.
- Do not surface plan credentials to the renderer, tests, or snapshots.
- Do not point Codex or Tutti Agent directly at a Chat-only Plan while declaring
wire_api = "responses"; the loopback Model Gateway is the protocol boundary. - Do not advertise
modelPlanBindingfor providers without a real injection path. - Do not let automated review close work; the ladder tops out at
auto_checkedwithout the user.