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

14 KiB
Raw Permalink Blame History

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

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.