Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
265 lines
14 KiB
Markdown
265 lines
14 KiB
Markdown
# 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/modelplan` owns the Plan model and validation.
|
||
- `services/tuttid/data/workspace` owns Plan persistence and retains legacy
|
||
binding persistence during the compatibility window.
|
||
- `services/tuttid/service/modelplan` owns CRUD, staged endpoint detection,
|
||
and deletion protection.
|
||
- `services/tuttid/service/workspaceagent` owns every new Harness-to-Plan
|
||
mapping. `services/tuttid/service/modelbinding` is a legacy adapter and
|
||
reference source; Desktop no longer calls its write routes.
|
||
- `packages/agent/daemon/providerregistry` declares 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/runtimeprep` contains 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-scoped `opencode.json` provider block via `OPENCODE_CONFIG`
|
||
(credential travels only as `TUTTI_MODEL_PLAN_API_KEY` with an `{env:…}`
|
||
reference in the file).
|
||
- `services/tuttid/service/modelgateway` owns the local
|
||
`Responses API ↔ Chat Completions API` transport adapter. It does not define
|
||
session lifecycle, expose a public daemon API, or act as a general
|
||
multi-provider protocol IR.
|
||
- `services/tuttid/service/agent` resolves 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
|
||
|
||
1. 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.
|
||
2. 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.
|
||
3. Before a new session starts, tuttid resolves that WorkspaceAgent and
|
||
validates its requested model against the Plan catalog.
|
||
4. 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 enters
|
||
`TUTTI_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. `runtimeprep` writes the
|
||
Codex-compatible provider table with `env_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
|
||
|
||
```text
|
||
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`/`opencode` consume
|
||
`openai` plans, `claude-code` consumes `anthropic` plans. 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.com`
|
||
and Kimi Coding (`api.kimi.com`) require `ANTHROPIC_API_KEY`/`x-api-key`;
|
||
other relay-style endpoints keep the bearer `ANTHROPIC_AUTH_TOKEN` default.
|
||
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 declares `provider_prefixed`, so its
|
||
composer/settings values carry the injected `tutti-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:0` on its own listener
|
||
and serves only authenticated `POST /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:
|
||
`developer` becomes `system`, `latest_reminder` and unknown internal roles
|
||
become `user`, 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 allow `system` at
|
||
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
|
||
omits `tool_choice` and `parallel_tool_calls`. Explicit selection of a
|
||
filtered tool, `required` with 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-empty
|
||
`previous_response_id` are 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 and `runtimeContext.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 — `modelSwitch` capability, 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 `modelPlanBinding` for providers without a real injection
|
||
path.
|
||
- Do not let automated review close work; the ladder tops out at
|
||
`auto_checked` without the user.
|