P3 internal#718: serve GET /templates selectable provider/model list FROM the registry (PR-A backend; NOT merged) #1977

Merged
hongming merged 1 commits from feat/internal-718-p3a-templates-from-registry into main 2026-05-28 03:02:48 +00:00
3 changed files with 410 additions and 6 deletions
@@ -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)
@@ -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
}
@@ -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)
}
}