From 2d0d070040a8216460df98193bece7cc6a9590e6 Mon Sep 17 00:00:00 2001 From: hongming Date: Wed, 27 May 2026 19:01:23 -0700 Subject: [PATCH] =?UTF-8?q?feat(workspace-server):=20P3=20internal#718=20?= =?UTF-8?q?=E2=80=94=20serve=20GET=20/templates=20selectable=20provider/mo?= =?UTF-8?q?del=20list=20from=20the=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 item 1 (retire-list #1 surface). GET /templates (templates.go List) now ANNOTATES each registry-known runtime's template with an authoritative registry-served selectable list, sourced from the provider registry (workspace-server/internal/providers, the P2-A synced SSOT) instead of the template's hand-authored config.yaml providers:/runtime_config.models block: - registry_backed: true when the runtime is in the registry runtimes: block. - registry_providers: the runtime's NATIVE provider set (ProvidersForRuntime), each with display_name + auth_env + billing_mode (platform_managed if the registry IsPlatform predicate holds, else byok) — the SSOT the canvas Provider dropdown consumes instead of its hardcoded VENDOR_LABELS map. - registry_models: the runtime's NATIVE model ids (ModelsForRuntime), each annotated with its DERIVED provider (DeriveProvider) + the billing_mode that provider implies — so the canvas shows the billing source of the DERIVED provider (folds in #1931 intent) and can render no model the registry did not list for the runtime ("only registered selectable"). Additive + federation-ready + fail-OPEN: the existing template-served Models/Providers/ProviderRegistry fields are UNCHANGED, so non-registry runtimes (external/mock/kimi/future third-party) and older canvases keep working — a runtime absent from the registry yields registry_backed=false and no synthesized block. NO hard-reject: templates whose model isn't registry-derivable are still served (WARN-level only; legacy-vocab reconcile is P4). Reuses the package-level providerRegistry() accessor + LLMBillingModePlatformManaged/ LLMBillingModeBYOK constants from llm_billing_mode.go (P2-B / #1972, now on main) — one accessor + one constant set for the package; both the billing derivation and this templates projection wrap the same providers.LoadManifest() SSOT and the same wire strings. Proxy ResolveUpstream / billing DeriveProvider untouched (P1/P2). Templates' own config.yaml providers: codegen untouched (P4). TDD: TestTemplatesList_RegistryServesSelectableModels (a template's bogus model id never leaks into the registry-served list; native ids present), TestTemplatesList_RegistryAnnotatesDerivedProviderAndBilling (derived provider + platform_managed/byok per model; provider display_name/auth_env/ billing from the registry), TestTemplatesList_NonRegistryRuntimeFallsOpenToTemplate (mock runtime: registry_backed=false, template fields untouched). All existing TestTemplatesList_* stay green (template-served fields unchanged). Rebased onto main after P2-B (#1972) landed; full handlers+providers suites green alongside it. internal#718 P3 — not merged; CTO merge-go after Five-Axis (UI/API-affecting). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handlers/templates.go | 79 +++++- .../internal/handlers/templates_registry.go | 112 +++++++++ .../internal/handlers/templates_test.go | 225 ++++++++++++++++++ 3 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 workspace-server/internal/handlers/templates_registry.go diff --git a/workspace-server/internal/handlers/templates.go b/workspace-server/internal/handlers/templates.go index aced29222..c5a0a49f1 100644 --- a/workspace-server/internal/handlers/templates.go +++ b/workspace-server/internal/handlers/templates.go @@ -95,6 +95,38 @@ type modelSpec struct { Name string `json:"name,omitempty" yaml:"name"` Provider string `json:"provider,omitempty" yaml:"provider"` RequiredEnv []string `json:"required_env,omitempty" yaml:"required_env"` + // BillingMode is the billing source the DERIVED provider implies: + // "platform_managed" (the closed core-only platform provider; Molecule + // owns the upstream key + the bill) or "byok" (any other provider; the + // tenant supplies its own key). Set ONLY on registry-served models + // (RegistryModels) where DeriveProvider resolved an owning provider; + // empty on template-served models. internal#718 P3 — the canvas reads + // this to show the billing-mode of the DERIVED provider instead of its + // hardcoded billingModeForProvider rule. + BillingMode string `json:"billing_mode,omitempty" yaml:"-"` +} + +// registryProviderView is the canvas-facing projection of a single registry +// Provider entry for a registry-known runtime: the stable name, the dropdown +// display label, the auth-env-var NAMES (never values), and the billing mode +// the provider implies. Sourced from the provider registry +// (internal/providers) so the canvas drops its hardcoded VENDOR_LABELS map +// and billingModeForProvider rule (internal#718 P3, retire-list #4/#5). +type registryProviderView struct { + // Name is the registry provider key (e.g. "anthropic-oauth", "platform"). + Name string `json:"name"` + // DisplayName is the canvas dropdown label (registry Provider.DisplayName). + DisplayName string `json:"display_name,omitempty"` + // AuthEnv is the env-var NAMES any one of which satisfies auth for this + // provider (registry Provider.AuthEnv). Names only, never secret values. + AuthEnv []string `json:"auth_env,omitempty"` + // BillingMode is "platform_managed" for the closed platform provider, + // "byok" otherwise — keyed off the registry IsPlatform predicate so the + // canvas shows the DERIVED provider's billing source. + BillingMode string `json:"billing_mode,omitempty"` + // Deprecated mirrors the registry's deprecated flag so the canvas can + // grey the provider out without breaking saved configs. + Deprecated bool `json:"deprecated,omitempty"` } // providerRegistryEntry mirrors a row from a template's top-level @@ -162,8 +194,29 @@ type templateSummary struct { // (omitempty); the canvas's existing per-model fallback continues // to work for them. ProviderRegistry []providerRegistryEntry `json:"provider_registry,omitempty"` - Skills []string `json:"skills"` - SkillCount int `json:"skill_count"` + // RegistryBacked is true when this template's runtime is known to the + // provider registry (internal/providers runtimes: block) and the + // RegistryProviders / RegistryModels fields below were populated from it. + // The canvas treats a registry-backed payload as AUTHORITATIVE for the + // selectable provider+model list (it drops its prefix-inference fallback) + // — "only registered selectable" follows because the canvas can render + // no option the registry did not serve. False = the runtime is not in the + // registry (federation / external / mock); the canvas keeps using the + // template-served Models/Providers + its heuristic. internal#718 P3. + RegistryBacked bool `json:"registry_backed,omitempty"` + // RegistryProviders is the runtime's NATIVE provider set from the + // registry (ProvidersForRuntime), each with its display label, auth-env + // names, and billing mode. Empty when !RegistryBacked. This is the SSOT + // the canvas Provider dropdown consumes instead of VENDOR_LABELS. + RegistryProviders []registryProviderView `json:"registry_providers,omitempty"` + // RegistryModels is the runtime's NATIVE model set from the registry + // (ModelsForRuntime), each annotated with its DERIVED provider and the + // billing mode that provider implies. Empty when !RegistryBacked. This is + // the SSOT the canvas Model dropdown consumes — a template can no longer + // surface a model the registry does not list for the runtime. + RegistryModels []modelSpec `json:"registry_models,omitempty"` + Skills []string `json:"skills"` + SkillCount int `json:"skill_count"` // ProvisionTimeoutSeconds lets a slow runtime declare its expected // cold-boot duration in its template manifest. Canvas's // ProvisioningTimeout banner respects this per-workspace via the @@ -243,9 +296,13 @@ func (h *TemplatesHandler) List(c *gin.Context) { log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err) return } + // normalizedRuntime strips the "-default" vanilla-variant suffix + // (claude-code-default → claude-code). Hoisted out of the + // known-runtime guard so the registry enrichment below can key off + // the same normalised name the guard validated. + normalizedRuntime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default") if raw.Runtime != "" { - runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default") - if _, ok := knownRuntimes[runtime]; !ok { + if _, ok := knownRuntimes[normalizedRuntime]; !ok { log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime) return } @@ -262,7 +319,7 @@ func (h *TemplatesHandler) List(c *gin.Context) { tier = h.wh.DefaultTier() } - templates = append(templates, templateSummary{ + summary := templateSummary{ ID: id, Name: raw.Name, Description: raw.Description, @@ -277,7 +334,17 @@ func (h *TemplatesHandler) List(c *gin.Context) { Skills: raw.Skills, SkillCount: len(raw.Skills), ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds, - }) + } + + // internal#718 P3: serve the SELECTABLE provider/model list from + // the provider registry for a registry-known runtime. Additive — + // the template-served Models/Providers above stay for non-registry + // runtimes + older canvases; this adds the authoritative + // registry_backed/registry_providers/registry_models block the + // current canvas prefers. Fail-open for unknown runtimes. + enrichFromRegistry(&summary, normalizedRuntime) + + templates = append(templates, summary) }) } walk(h.cacheDir) diff --git a/workspace-server/internal/handlers/templates_registry.go b/workspace-server/internal/handlers/templates_registry.go new file mode 100644 index 000000000..c9a431b63 --- /dev/null +++ b/workspace-server/internal/handlers/templates_registry.go @@ -0,0 +1,112 @@ +package handlers + +// templates_registry.go — internal#718 P3: serve the GET /templates selectable +// provider/model list FROM the provider registry (workspace-server/internal/ +// providers) instead of from each template's hand-authored config.yaml +// `providers:` / `runtime_config.models` block. +// +// The registry (P2-A synced copy of the canonical CP providers.yaml) is the +// SSOT for "which providers + models does runtime R natively support" and +// "which derived provider owns model M" (DeriveProvider) and "is that provider +// the closed platform set" (IsPlatform). This file projects that into the +// templates payload's registry_backed / registry_providers / registry_models +// fields so the canvas can drop its hardcoded VENDOR_LABELS / +// billingModeForProvider vocabularies (retire-list #4/#5) and physically can't +// render an option the registry didn't serve. +// +// Federation-ready, fail-OPEN: a runtime ABSENT from the registry's runtimes: +// block (external / mock / kimi / a future third-party runtime) yields +// RegistryBacked=false and an empty registry block — the template's own fields +// stay authoritative. No behavior change for non-registry runtimes. +// +// NOTE: this reuses the package-level providerRegistry() accessor + +// LLMBillingModePlatformManaged / LLMBillingModeBYOK constants from +// llm_billing_mode.go (added by P2-B, internal#718 #1972, now on main) — both +// the billing-derivation and this templates projection wrap the same +// providers.LoadManifest() SSOT and the same platform_managed/byok wire +// strings, so there is one accessor + one constant set for the package. + +import ( + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/providers" +) + +// billingModeForRegistryProvider maps a registry Provider to the billing mode +// it implies: platform_managed for the closed core-only platform provider, +// byok for everything else. Keyed off the registry IsPlatform predicate — +// the same one billing/credential emission (llm_billing_mode.go) keys off the +// DERIVED provider — so the canvas shows the true billing source of the +// resolved provider. Returns the same LLMBillingMode* wire strings the Config +// tab's billing-mode switch sends. +func billingModeForRegistryProvider(p providers.Provider) string { + if p.IsPlatform() { + return LLMBillingModePlatformManaged + } + return LLMBillingModeBYOK +} + +// enrichFromRegistry populates the registry-served fields on a templateSummary +// when its runtime is known to the provider registry. It is a no-op (leaves +// RegistryBacked=false and the registry slices nil) for a runtime the registry +// does not know — the federation/fail-open path. +// +// runtime is the template's already-normalised runtime string (the caller +// strips the "-default" suffix before calling, matching List's existing +// knownRuntimes check). +func enrichFromRegistry(summary *templateSummary, runtime string) { + m, err := providerRegistry() + if err != nil || m == nil { + return // fail open — registry load defect; keep template-served fields. + } + + provs, err := m.ProvidersForRuntime(runtime) + if err != nil { + // Runtime not in the registry runtimes: block (external / mock / kimi + // / future third-party). Fail open: the template's own fields stay + // authoritative; no registry annotation. + return + } + + // registry_providers — the runtime's native provider set, in registry + // declared order, projected to the canvas-facing view. + views := make([]registryProviderView, 0, len(provs)) + for _, p := range provs { + views = append(views, registryProviderView{ + Name: p.Name, + DisplayName: p.DisplayName, + AuthEnv: p.AuthEnv, + BillingMode: billingModeForRegistryProvider(p), + Deprecated: p.Deprecated, + }) + } + + // registry_models — the runtime's native model ids, each annotated with + // the DERIVED owning provider + the billing mode it implies. DeriveProvider + // is the SSOT for model→provider; we pass nil availableAuthEnv because a + // template manifest has no per-workspace auth env, and the registry's + // exact-id mapping resolves every native model id unambiguously (the + // claude-code kimi split is by exact id, not a shared prefix). + models, err := m.ModelsForRuntime(runtime) + if err != nil { + // ProvidersForRuntime succeeded but ModelsForRuntime did not — should + // be impossible (both gate on the same Runtimes entry), but fail open + // rather than serve a half-populated block. + return + } + regModels := make([]modelSpec, 0, len(models)) + for _, id := range models { + ms := modelSpec{ID: id} + if derived, derr := m.DeriveProvider(runtime, id, nil); derr == nil { + ms.Provider = derived.Name + ms.BillingMode = billingModeForRegistryProvider(derived) + } + // If DeriveProvider errors (ambiguous/overlap — a manifest defect the + // loader's tests pin against), still serve the id without a provider + // annotation rather than dropping it; the canvas treats an + // un-annotated registry model as selectable-but-unlabelled. + regModels = append(regModels, ms) + } + + summary.RegistryBacked = true + summary.RegistryProviders = views + summary.RegistryModels = regModels +} diff --git a/workspace-server/internal/handlers/templates_test.go b/workspace-server/internal/handlers/templates_test.go index 0c9c55c8d..eebbf600c 100644 --- a/workspace-server/internal/handlers/templates_test.go +++ b/workspace-server/internal/handlers/templates_test.go @@ -1329,3 +1329,228 @@ func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) { }) } } + +// ============================================================================ +// internal#718 P3 — GET /templates serves the selectable provider/model list +// FROM the provider registry (workspace-server/internal/providers), not from +// each template's hand-authored config.yaml. Additive: the registry-served +// fields (registry_backed / registry_providers / registry_models) ride +// ALONGSIDE the existing template-served fields so non-registry runtimes and +// older canvases keep working. The canvas (PR-B) prefers the registry block; +// "only registered selectable" follows because the registry block is the +// authoritative list for a registry-known runtime. +// ============================================================================ + +// TestTemplatesList_RegistryServesSelectableModels pins the core P3 contract: +// for a runtime the provider registry knows (claude-code), /templates serves +// the registry's NATIVE model ids — regardless of what the template's +// config.yaml runtime_config.models happens to list. A template author can no +// longer surface an unregistered model into the canvas dropdown. +func TestTemplatesList_RegistryServesSelectableModels(t *testing.T) { + tmpDir := t.TempDir() + tmplDir := filepath.Join(tmpDir, "claude-code-default") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Deliberately list a BOGUS model the registry does not know. The + // registry-served list must NOT contain it. + configYaml := `name: Claude Code +runtime: claude-code +runtime_config: + model: claude-sonnet-4-6 + models: + - id: totally-made-up-model + name: Not In Registry +skills: [] +` + if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + handler := NewTemplatesHandler(tmpDir, nil, nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/templates", nil) + handler.List(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp []templateSummary + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + if len(resp) != 1 { + t.Fatalf("expected 1 template, got %d", len(resp)) + } + got := resp[0] + + if !got.RegistryBacked { + t.Fatalf("claude-code is a registry-known runtime; RegistryBacked must be true") + } + + // The registry-served model set must be the claude-code native set + // (anthropic-oauth: sonnet/opus/haiku, anthropic-api: claude-*-4-*, + // kimi-coding: kimi-*, minimax: MiniMax-*, platform: vendor/model ids). + // It must NOT contain the template's bogus id. + regModelIDs := map[string]bool{} + for _, m := range got.RegistryModels { + regModelIDs[m.ID] = true + } + if regModelIDs["totally-made-up-model"] { + t.Errorf("RegistryModels leaked the template's unregistered model id") + } + for _, want := range []string{"sonnet", "opus", "claude-opus-4-7", "anthropic/claude-opus-4-7"} { + if !regModelIDs[want] { + t.Errorf("RegistryModels missing native model %q; got %v", want, regModelIDs) + } + } +} + +// TestTemplatesList_RegistryAnnotatesDerivedProviderAndBilling pins that each +// registry-served model carries its DERIVED provider name + a billing_mode +// reflecting whether that derived provider is the closed platform set +// (platform_managed) or BYOK (byok). This is what the canvas Config tab reads +// to show the billing-mode of the DERIVED provider (folds in #1931 intent), +// instead of its hardcoded billingModeForProvider rule. +func TestTemplatesList_RegistryAnnotatesDerivedProviderAndBilling(t *testing.T) { + tmpDir := t.TempDir() + tmplDir := filepath.Join(tmpDir, "claude-code-default") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + configYaml := `name: Claude Code +runtime: claude-code +runtime_config: + model: claude-sonnet-4-6 +skills: [] +` + if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + handler := NewTemplatesHandler(tmpDir, nil, nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/templates", nil) + handler.List(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp []templateSummary + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + got := resp[0] + + billByModel := map[string]string{} + provByModel := map[string]string{} + for _, m := range got.RegistryModels { + billByModel[m.ID] = m.BillingMode + provByModel[m.ID] = m.Provider + } + + // A BYOK API model derives to anthropic-api → byok. + if provByModel["claude-opus-4-7"] != "anthropic-api" { + t.Errorf("claude-opus-4-7 derived provider: want anthropic-api, got %q", provByModel["claude-opus-4-7"]) + } + if billByModel["claude-opus-4-7"] != "byok" { + t.Errorf("claude-opus-4-7 billing_mode: want byok, got %q", billByModel["claude-opus-4-7"]) + } + // A platform-namespaced model derives to the closed platform provider → + // platform_managed. + if provByModel["anthropic/claude-opus-4-7"] != "platform" { + t.Errorf("anthropic/claude-opus-4-7 derived provider: want platform, got %q", provByModel["anthropic/claude-opus-4-7"]) + } + if billByModel["anthropic/claude-opus-4-7"] != "platform_managed" { + t.Errorf("anthropic/claude-opus-4-7 billing_mode: want platform_managed, got %q", billByModel["anthropic/claude-opus-4-7"]) + } + + // registry_providers carries the provider display_name + auth_env + + // billing_mode for the dropdown labels — sourced from the registry, not + // the canvas VENDOR_LABELS map. + byName := map[string]registryProviderView{} + for _, p := range got.RegistryProviders { + byName[p.Name] = p + } + oauth, ok := byName["anthropic-oauth"] + if !ok { + t.Fatalf("registry_providers missing anthropic-oauth; got %v", byName) + } + if oauth.DisplayName != "Claude Code subscription" { + t.Errorf("anthropic-oauth display_name: want %q, got %q", "Claude Code subscription", oauth.DisplayName) + } + if oauth.BillingMode != "byok" { + t.Errorf("anthropic-oauth billing_mode: want byok, got %q", oauth.BillingMode) + } + if len(oauth.AuthEnv) != 1 || oauth.AuthEnv[0] != "CLAUDE_CODE_OAUTH_TOKEN" { + t.Errorf("anthropic-oauth auth_env: want [CLAUDE_CODE_OAUTH_TOKEN], got %v", oauth.AuthEnv) + } + plat, ok := byName["platform"] + if !ok || plat.BillingMode != "platform_managed" { + t.Errorf("platform provider billing_mode: want platform_managed, got %+v", plat) + } +} + +// TestTemplatesList_NonRegistryRuntimeFallsOpenToTemplate pins federation- +// readiness: for a runtime the registry does NOT know (a hypothetical +// third-party / external-like runtime), /templates does NOT set +// RegistryBacked and does NOT synthesize a registry block — the template's +// own config.yaml fields remain the source, unchanged. No behavior change for +// non-registry runtimes. +func TestTemplatesList_NonRegistryRuntimeFallsOpenToTemplate(t *testing.T) { + tmpDir := t.TempDir() + tmplDir := filepath.Join(tmpDir, "byo-runtime") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // "mock" is a known runtime to the manifest allowlist (so List doesn't + // skip it) but is NOT in the provider registry's runtimes: block. + configYaml := `name: Mock Runtime +runtime: mock +runtime_config: + model: canned-reply + providers: [some-byo-provider] + models: + - id: canned-reply + name: Canned Reply +skills: [] +` + if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + handler := NewTemplatesHandler(tmpDir, nil, nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/templates", nil) + handler.List(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp []templateSummary + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + if len(resp) != 1 { + t.Fatalf("expected 1 template, got %d", len(resp)) + } + got := resp[0] + + if got.RegistryBacked { + t.Errorf("mock is NOT a registry runtime; RegistryBacked must be false") + } + if len(got.RegistryModels) != 0 || len(got.RegistryProviders) != 0 { + t.Errorf("non-registry runtime must not synthesize a registry block; got models=%v providers=%v", + got.RegistryModels, got.RegistryProviders) + } + // Template-served fields untouched. + if len(got.Models) != 1 || got.Models[0].ID != "canned-reply" { + t.Errorf("template Models unchanged: got %+v", got.Models) + } + if !reflect.DeepEqual(got.Providers, []string{"some-byo-provider"}) { + t.Errorf("template Providers unchanged: got %v", got.Providers) + } +} -- 2.52.0