diff --git a/workspace-server/internal/handlers/org_import.go b/workspace-server/internal/handlers/org_import.go index 40a67604..70151e09 100644 --- a/workspace-server/internal/handlers/org_import.go +++ b/workspace-server/internal/handlers/org_import.go @@ -51,11 +51,10 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX model = defaults.Model } if model == "" { - if runtime == "claude-code" { - model = "sonnet" - } else { - model = "anthropic:claude-opus-4-7" - } + // SSOT: per-runtime defaults live in models/runtime_defaults.go + // (see RFC #2873). Consolidated from a duplicate of the same + // branch in workspace_provision.go. + model = models.DefaultModel(runtime) } tier := ws.Tier if tier == 0 { diff --git a/workspace-server/internal/handlers/workspace_provision.go b/workspace-server/internal/handlers/workspace_provision.go index 7734aff0..981ee5da 100644 --- a/workspace-server/internal/handlers/workspace_provision.go +++ b/workspace-server/internal/handlers/workspace_provision.go @@ -534,11 +534,10 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model // Generate a minimal config.yaml model := payload.Model if model == "" { - if runtime == "claude-code" { - model = "sonnet" - } else { - model = "anthropic:claude-opus-4-7" - } + // SSOT: per-runtime defaults live in models/runtime_defaults.go + // (see RFC #2873). Was previously duplicated here AND in + // org_import.go; consolidating prevents silent drift. + model = models.DefaultModel(runtime) } // Sanitize name/role/model for YAML safety — always double-quote so diff --git a/workspace-server/internal/models/runtime_defaults.go b/workspace-server/internal/models/runtime_defaults.go new file mode 100644 index 00000000..320586e8 --- /dev/null +++ b/workspace-server/internal/models/runtime_defaults.go @@ -0,0 +1,39 @@ +package models + +// runtime_defaults.go — single source of truth for per-runtime defaults +// the platform applies when the operator/agent didn't supply a value. +// +// Why this lives in models/ (not handlers/): default selection is a +// pure data fact about the runtime, not handler logic. Multiple +// callers (Create-workspace handler, org-import handler, future +// auto-provision paths) need the same answer; concentrating the +// rule here means one edit when a runtime's default changes. +// +// Related work (RFC #2873): this is the seed for a future +// `RuntimeConfig` interface that will also expose `ProvisioningTimeout()`, +// `CapabilitiesSupported()`, and other per-runtime facts. For now the +// surface is one helper — extracted from the duplicate branch in +// workspace_provision.go:537 and org_import.go:54 that diverged silently +// during refactors before this consolidation. + +// DefaultModel returns the model slug to use when a workspace is +// created without an explicit model and the runtime can't infer one +// from its own config. +// +// - claude-code: "sonnet" — Anthropic's CLI accepts the short +// name and resolves it via the operator's anthropic-oauth or +// ANTHROPIC_API_KEY chain. +// - everything else (hermes, langgraph, crewai, autogen, deepagents, +// codex, openclaw, gemini-cli, external, ""): a fully-qualified +// vendor:model slug that the universal MODEL_PROVIDER chain in +// molecule-core PR #247 can route via per-vendor required_env. +// +// The function never returns an empty string; an unknown runtime +// gets the universal default rather than failing closed (matches the +// pre-refactor behavior — both call sites used the same fallback). +func DefaultModel(runtime string) string { + if runtime == "claude-code" { + return "sonnet" + } + return "anthropic:claude-opus-4-7" +} diff --git a/workspace-server/internal/models/runtime_defaults_test.go b/workspace-server/internal/models/runtime_defaults_test.go new file mode 100644 index 00000000..bab673ac --- /dev/null +++ b/workspace-server/internal/models/runtime_defaults_test.go @@ -0,0 +1,62 @@ +package models + +import "testing" + +// TestDefaultModel pins the contract: known runtimes return their +// expected default; unknowns and the empty string fall through to the +// universal default. Add new runtimes here as `case` entries — pre-fix +// adding a runtime required two source edits + an audit; post-SSOT it +// requires one entry in DefaultModel + one assertion here. +func TestDefaultModel(t *testing.T) { + cases := []struct { + runtime string + want string + }{ + // Known runtimes. + {"claude-code", "sonnet"}, + + // Universal fallback for everything else. Each runtime is named + // explicitly so a future drift (e.g., adding a hermes-specific + // branch) shows up as a failure on the runtime that drifted, not + // as a generic "unknown" failure. + {"hermes", "anthropic:claude-opus-4-7"}, + {"langgraph", "anthropic:claude-opus-4-7"}, + {"crewai", "anthropic:claude-opus-4-7"}, + {"autogen", "anthropic:claude-opus-4-7"}, + {"deepagents", "anthropic:claude-opus-4-7"}, + {"codex", "anthropic:claude-opus-4-7"}, + {"openclaw", "anthropic:claude-opus-4-7"}, + {"gemini-cli", "anthropic:claude-opus-4-7"}, + {"external", "anthropic:claude-opus-4-7"}, + + // Unknown / empty — fall through to universal default rather + // than failing closed. Pre-refactor both call sites also fell + // through; pinning the existing behavior, not changing it. + {"", "anthropic:claude-opus-4-7"}, + {"some-future-runtime", "anthropic:claude-opus-4-7"}, + {"CLAUDE-CODE", "anthropic:claude-opus-4-7"}, // case-sensitive — matches prior behavior + } + for _, tc := range cases { + t.Run(tc.runtime, func(t *testing.T) { + got := DefaultModel(tc.runtime) + if got != tc.want { + t.Errorf("DefaultModel(%q) = %q, want %q", tc.runtime, got, tc.want) + } + }) + } +} + +// TestDefaultModel_NeverEmpty — invariant: no input produces an empty +// string. The handlers that consume this would write empty into +// config.yaml, which the runtime then can't dispatch — pinning the +// non-empty contract here protects against a future "return early on +// unknown runtime" change that would silently break workspace creation. +func TestDefaultModel_NeverEmpty(t *testing.T) { + for _, runtime := range []string{ + "", "claude-code", "hermes", "unknown-runtime", + } { + if got := DefaultModel(runtime); got == "" { + t.Errorf("DefaultModel(%q) returned empty string", runtime) + } + } +}