Files
tutti/docs/architecture/model-access-plans.md
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

265 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.