provider-SSOT P2-B -> main: billing derives from provider (re-target #1971) #1972
@@ -255,22 +255,20 @@ func TestExtended_SecretsListEmpty(t *testing.T) {
|
||||
// ---------- TestSecretsSet (Extended) ----------
|
||||
|
||||
func TestExtended_SecretsSet(t *testing.T) {
|
||||
// internal#691: the per-workspace strip gate now defaults to platform_managed
|
||||
// on empty MOLECULE_LLM_BILLING_MODE (closed default). This test's intent is
|
||||
// the happy path of persisting a vendor key, so put the org into byok which
|
||||
// matches the pre-#691 implicit behavior of an unset env.
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "byok")
|
||||
// internal#718 P2-B: the per-workspace strip gate keys off the DERIVED mode
|
||||
// (org rung retired). This test's intent is the happy path of persisting a
|
||||
// vendor key on a byok workspace; the realistic way a workspace is byok for
|
||||
// a direct vendor-key write is an explicit operator override (the escape
|
||||
// hatch the reject error itself points to: PUT /admin/.../llm-billing-mode).
|
||||
// The override short-circuits the resolver to byok in a single read, so the
|
||||
// bypass-list check is skipped and the write proceeds.
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed") // org env ignored now
|
||||
mock := setupTestDB(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
// internal#691: secrets.Set now consults ResolveLLMBillingMode before the
|
||||
// strip gate. Mock returns no row → resolver falls through to the org
|
||||
// default (byok, set via t.Setenv above) → bypass-list check is skipped
|
||||
// and the write proceeds. This pattern is the test-side mirror of the
|
||||
// real-prod fall-through behavior for a fresh workspace with no override.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("22222222-2222-2222-2222-222222222222").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
|
||||
// Expect INSERT (encrypted value is dynamic, use AnyArg)
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
|
||||
@@ -43,10 +43,36 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/crypto"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/providers"
|
||||
)
|
||||
|
||||
// providerManifest is the parsed provider registry, loaded once. The registry
|
||||
// is embedded (go:embed, no network) and immutable for the process lifetime, so
|
||||
// a single Load is safe to memoize. A load failure is cached too (registryErr):
|
||||
// it can only happen on a malformed embedded YAML, which is a build-time defect
|
||||
// the verify-providers-gen + sync gates already catch, so failing closed
|
||||
// (treat as "cannot derive" → platform default) is correct and we don't retry.
|
||||
var (
|
||||
providerRegistryOnce sync.Once
|
||||
providerRegistryManifest *providers.Manifest
|
||||
providerRegistryErr error
|
||||
)
|
||||
|
||||
func providerRegistry() (*providers.Manifest, error) {
|
||||
providerRegistryOnce.Do(func() {
|
||||
providerRegistryManifest, providerRegistryErr = providers.LoadManifest()
|
||||
if providerRegistryErr != nil {
|
||||
log.Printf("llm_billing_mode: FATAL — provider registry failed to load: %v (billing will default-closed to platform_managed)", providerRegistryErr)
|
||||
}
|
||||
})
|
||||
return providerRegistryManifest, providerRegistryErr
|
||||
}
|
||||
|
||||
// Constants mirror molecule-controlplane/internal/credits/llm_billing.go.
|
||||
// Kept as string literals (not imports) because workspace-server has no
|
||||
// build-time dependency on the CP module; the values are stable wire
|
||||
@@ -67,6 +93,19 @@ const (
|
||||
BillingModeSourceWorkspaceOverride BillingModeSource = "workspace_override"
|
||||
BillingModeSourceOrgDefault BillingModeSource = "org_default"
|
||||
BillingModeSourceConstantFallback BillingModeSource = "constant_fallback"
|
||||
// BillingModeSourceDerivedProvider means the mode was DERIVED from the
|
||||
// workspace's (runtime, model) via the provider registry — the SSOT
|
||||
// (internal#718 P2-B). IsPlatform(derived) → platform_managed, else byok.
|
||||
// This is the highest-precedence source after an explicit operator override
|
||||
// and SUPERSEDES the prior stored-LLM_PROVIDER read (#1966).
|
||||
BillingModeSourceDerivedProvider BillingModeSource = "derived_provider"
|
||||
// BillingModeSourceDerivedDefault means the registry could not derive a
|
||||
// provider for the (runtime, model) — no model, unknown runtime,
|
||||
// unregistered/ambiguous model — so the mode defaulted closed to
|
||||
// platform_managed (CTO-confirmed "unset → platform default"). Distinct from
|
||||
// derived_provider so operators can see "we defaulted" vs "we derived
|
||||
// platform".
|
||||
BillingModeSourceDerivedDefault BillingModeSource = "derived_default"
|
||||
)
|
||||
|
||||
// BillingModeResolution is the structured answer the admin GET route returns
|
||||
@@ -74,11 +113,18 @@ const (
|
||||
// shape, so the resolver test asserts both the mode AND the source per case
|
||||
// (catches a bug where the right mode is returned via the wrong layer).
|
||||
type BillingModeResolution struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ResolvedMode string `json:"resolved_mode"`
|
||||
WorkspaceOverride *string `json:"workspace_override"` // nil = inherit
|
||||
OrgDefault string `json:"org_default"` // already default-closed by CP
|
||||
Source BillingModeSource `json:"source"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ResolvedMode string `json:"resolved_mode"`
|
||||
WorkspaceOverride *string `json:"workspace_override"` // nil = inherit
|
||||
OrgDefault string `json:"org_default"` // RETIRED as a billing source (internal#718 P2-B); always platform_managed, kept for wire-compat
|
||||
Source BillingModeSource `json:"source"`
|
||||
// ProviderSelection surfaces the DERIVED provider name (internal#718 P2-B)
|
||||
// when the mode came from the registry derivation — the literal provider the
|
||||
// (runtime, model) resolved to (e.g. "platform", "kimi-coding", "openai"), or
|
||||
// the raw model id when derivation failed. nil when an explicit operator
|
||||
// override or the empty-id default decided. Lets the admin route answer "why
|
||||
// is this workspace byok?" with the derived provider, not a stored value.
|
||||
ProviderSelection *string `json:"provider_selection"`
|
||||
}
|
||||
|
||||
// isKnownBillingMode is the enum-recognizer for the resolver's default-closed
|
||||
@@ -95,24 +141,137 @@ func isKnownBillingMode(s string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeOrgDefault applies the same default-closed contract to the
|
||||
// org-level input as the workspace override gets. The org_default arrives
|
||||
// from tenant_config which already COALESCEs NULL → platform_managed at the
|
||||
// CP SQL layer, but we DO NOT trust that contract here — if CP regresses or
|
||||
// the tenant_config env wasn't populated (race on boot), we still default-
|
||||
// close. Same principle: never honor a garbled value.
|
||||
func normalizeOrgDefault(orgMode string) string {
|
||||
if isKnownBillingMode(orgMode) {
|
||||
return orgMode
|
||||
// readWorkspaceBillingOverride reads the OPTIONAL explicit operator override
|
||||
// (workspaces.llm_billing_mode). Returns:
|
||||
//
|
||||
// (mode, true, nil) — a recognized override is set → operator pinned the mode
|
||||
// ("", false, nil) — NULL / garbled / row-missing → no explicit override
|
||||
// ("", false, err) — DB error → caller defaults closed + propagates
|
||||
//
|
||||
// internal#718 P2-B retires the org rung; this column is the ONLY stored
|
||||
// billing signal that survives, and ONLY as an explicit override on top of the
|
||||
// derived provider (CTO 2026-05-27).
|
||||
func readWorkspaceBillingOverride(ctx context.Context, workspaceID string) (string, bool, error) {
|
||||
var wsOverride sql.NullString
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT llm_billing_mode FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsOverride)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", false, nil
|
||||
case err != nil:
|
||||
return "", false, fmt.Errorf("resolve workspace llm_billing_mode override for %s: %w", workspaceID, err)
|
||||
}
|
||||
return LLMBillingModePlatformManaged
|
||||
if wsOverride.Valid && isKnownBillingMode(wsOverride.String) {
|
||||
return wsOverride.String, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// ResolveLLMBillingMode is the canonical resolver. Every code path that
|
||||
// previously gated on `os.Getenv("MOLECULE_LLM_BILLING_MODE") == "platform_managed"`
|
||||
// must call this instead and gate on the returned mode. The architectural
|
||||
// test (resolver_ast_test.go) asserts there is no remaining call site of
|
||||
// the old shape outside the resolver-input wiring.
|
||||
// ResolveLLMBillingModeDerived is the SSOT billing-mode resolver (internal#718
|
||||
// P2-B). It DERIVES the provider from (runtime, model) via the provider
|
||||
// registry and decides platform-vs-byok from IsPlatform(derived) — it does NOT
|
||||
// read a stored LLM_PROVIDER (superseding #1966's stored-read approach) and
|
||||
// does NOT read the org rung (retired, CTO 2026-05-27).
|
||||
//
|
||||
// Precedence (highest first):
|
||||
//
|
||||
// 1. EXPLICIT operator override (workspaces.llm_billing_mode, a recognized
|
||||
// value). The only stored billing signal that survives — an escape hatch,
|
||||
// not the primary signal.
|
||||
// 2. DERIVE: providers.DeriveProvider(runtime, model, availableAuthEnv).
|
||||
// - resolves to the closed `platform` provider → platform_managed
|
||||
// - resolves to any other (BYOK/third-party) provider → byok ← THE FIX
|
||||
// 3. DEFAULT-CLOSED: derive fails (no model, unknown runtime, unregistered or
|
||||
// ambiguous model) → platform_managed (CTO "unset → platform default"). A
|
||||
// derive failure NEVER silently flips a workspace to byok (which would
|
||||
// strip the platform creds it may legitimately need).
|
||||
//
|
||||
// availableAuthEnv is the set of auth-env-var NAMES present for the workspace
|
||||
// (never secret values) — the same disambiguation input DeriveProvider uses to
|
||||
// split anthropic-oauth from anthropic-api. May be nil.
|
||||
//
|
||||
// A returned error never prevents a decision: ResolvedMode is always a valid
|
||||
// enum value (default-closed). The error is informational (log + surface).
|
||||
func ResolveLLMBillingModeDerived(ctx context.Context, workspaceID, runtime, model string, availableAuthEnv []string) (BillingModeResolution, error) {
|
||||
res := BillingModeResolution{
|
||||
WorkspaceID: workspaceID,
|
||||
// OrgDefault is retired as a billing source (internal#718 P2-B). Kept on
|
||||
// the struct for wire-compat (admin route / CP mirror) but always the
|
||||
// closed constant — never consulted in the decision.
|
||||
OrgDefault: LLMBillingModePlatformManaged,
|
||||
}
|
||||
|
||||
// Pre-provision context (no workspace row yet): no override to read, default
|
||||
// closed. (DeriveProvider could still run from the passed runtime/model, but
|
||||
// the no-id path historically does no DB work and the strip gate only runs
|
||||
// post-create, so keep it a pure default to preserve that contract.)
|
||||
if workspaceID == "" {
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
res.Source = BillingModeSourceDerivedDefault
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Precedence 1: explicit operator override.
|
||||
if mode, ok, err := readWorkspaceBillingOverride(ctx, workspaceID); err != nil {
|
||||
// DB error — default closed AND propagate (never flip on a transient error).
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
return res, err
|
||||
} else if ok {
|
||||
m := mode
|
||||
res.WorkspaceOverride = &m
|
||||
res.ResolvedMode = mode
|
||||
res.Source = BillingModeSourceWorkspaceOverride
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Precedence 2: DERIVE the provider from (runtime, model).
|
||||
manifest, mErr := providerRegistry()
|
||||
if mErr != nil || manifest == nil {
|
||||
// Registry unavailable (malformed embedded YAML — a build-time defect the
|
||||
// gates catch). Default closed.
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
res.Source = BillingModeSourceDerivedDefault
|
||||
return res, mErr
|
||||
}
|
||||
provider, dErr := manifest.DeriveProvider(runtime, model, availableAuthEnv)
|
||||
if dErr != nil {
|
||||
// No model / unknown runtime / unregistered / ambiguous → default closed.
|
||||
// NOT an error to the caller: an unregistered model is a legitimate
|
||||
// "we can't say it's BYOK, so bill the platform default" outcome, and the
|
||||
// only-registered gate at the create/config API is where an unregistered
|
||||
// model is rejected loudly. Here we just fail closed for safety.
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
res.Source = BillingModeSourceDerivedDefault
|
||||
sel := model
|
||||
if sel != "" {
|
||||
res.ProviderSelection = &sel
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
derivedName := provider.Name
|
||||
res.ProviderSelection = &derivedName
|
||||
res.Source = BillingModeSourceDerivedProvider
|
||||
if provider.IsPlatform() {
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
} else {
|
||||
// A specific (non-platform) vendor was derived → bring-your-own-key.
|
||||
res.ResolvedMode = LLMBillingModeBYOK
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ResolveLLMBillingMode is the legacy-signature resolver retained for callers
|
||||
// that do not have (runtime, model) in hand (the admin GET/PUT route and the
|
||||
// secrets remote-pull path). It reads the workspace's stored runtime + model +
|
||||
// available auth env from the DB and delegates to the DERIVED resolver
|
||||
// (internal#718 P2-B) — the orgMode parameter is RETIRED (the org rung is no
|
||||
// longer a billing source) and is ignored; it stays in the signature only to
|
||||
// avoid churning the two callers in this PR. The architectural test asserts no
|
||||
// remaining code path gates on os.Getenv("MOLECULE_LLM_BILLING_MODE") for the
|
||||
// strip decision (that env is no longer read into the decision at all).
|
||||
//
|
||||
// Returning an error does NOT prevent the caller from making a decision —
|
||||
// the returned mode is always a valid enum value (default-closed to
|
||||
@@ -120,75 +279,160 @@ func normalizeOrgDefault(orgMode string) string {
|
||||
// branch. The error is informational: log it, surface it to operators, but
|
||||
// the strip-gate decision is already safe.
|
||||
func ResolveLLMBillingMode(ctx context.Context, workspaceID, orgMode string) (BillingModeResolution, error) {
|
||||
res := BillingModeResolution{
|
||||
WorkspaceID: workspaceID,
|
||||
OrgDefault: normalizeOrgDefault(orgMode),
|
||||
}
|
||||
_ = orgMode // org rung retired (internal#718 P2-B); parameter ignored.
|
||||
|
||||
if workspaceID == "" {
|
||||
// No workspace ID = pre-provision context (templating, validation).
|
||||
// Resolve against the org default only, no DB read.
|
||||
res.ResolvedMode = res.OrgDefault
|
||||
res.Source = BillingModeSourceOrgDefault
|
||||
if !isKnownBillingMode(orgMode) {
|
||||
// Org default was garbled/NULL and we clamped to platform_managed.
|
||||
// Mark the source as constant_fallback so the operator can see
|
||||
// the clamp happened, not that the org "really" said platform_managed.
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
}
|
||||
return res, nil
|
||||
// Pre-provision context (templating, validation): default closed, no DB.
|
||||
return ResolveLLMBillingModeDerived(ctx, "", "", "", nil)
|
||||
}
|
||||
|
||||
var wsOverride sql.NullString
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT llm_billing_mode FROM workspaces WHERE id = $1`,
|
||||
// Precedence 1: explicit operator override. Read it FIRST so an overridden
|
||||
// workspace short-circuits without the extra runtime/secrets reads (and so
|
||||
// the query order is override → runtime → secrets, matching the derived
|
||||
// resolver's own override-first precedence).
|
||||
if mode, ok, err := readWorkspaceBillingOverride(ctx, workspaceID); err != nil {
|
||||
return BillingModeResolution{
|
||||
WorkspaceID: workspaceID,
|
||||
OrgDefault: LLMBillingModePlatformManaged,
|
||||
ResolvedMode: LLMBillingModePlatformManaged,
|
||||
Source: BillingModeSourceConstantFallback,
|
||||
}, err
|
||||
} else if ok {
|
||||
m := mode
|
||||
return BillingModeResolution{
|
||||
WorkspaceID: workspaceID,
|
||||
OrgDefault: LLMBillingModePlatformManaged,
|
||||
ResolvedMode: mode,
|
||||
WorkspaceOverride: &m,
|
||||
Source: BillingModeSourceWorkspaceOverride,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Precedence 2: DERIVE. Read the stored (runtime, model, available-auth-env)
|
||||
// so the derived resolver can DeriveProvider for callers that don't carry
|
||||
// them (admin route, secrets remote-pull). A read miss/error degrades
|
||||
// gracefully: pass the empty/partial inputs through — DeriveProvider then
|
||||
// errors and the derived resolver defaults closed to platform_managed.
|
||||
//
|
||||
// ResolveLLMBillingModeDerived re-reads the override (NULL again here) before
|
||||
// deriving; that one extra cheap read keeps the derived resolver a complete,
|
||||
// independently-callable SSOT rather than splitting its precedence across two
|
||||
// functions.
|
||||
runtime, model, authEnv := readWorkspaceDeriveInputs(ctx, workspaceID)
|
||||
return ResolveLLMBillingModeDerived(ctx, workspaceID, runtime, model, authEnv)
|
||||
}
|
||||
|
||||
// readWorkspaceDeriveInputs loads the workspace's stored runtime + selected
|
||||
// model + the auth-env-var NAMES present in its secrets — the inputs
|
||||
// DeriveProvider needs. Best-effort: any read error returns whatever was
|
||||
// gathered (the derived resolver fails closed on incomplete inputs). The model
|
||||
// is the MODEL workspace_secret (the canvas-picked id, written by setModelSecret
|
||||
// / Create); runtime is the workspaces.runtime column (defaults claude-code).
|
||||
// availableAuthEnv is the subset of secret KEYS that are recognized provider
|
||||
// auth-env names (never values), so DeriveProvider's auth-env tie-break can fire
|
||||
// the same way it does on the provision path.
|
||||
func readWorkspaceDeriveInputs(ctx context.Context, workspaceID string) (runtime, model string, availableAuthEnv []string) {
|
||||
var rt sql.NullString
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT runtime FROM workspaces WHERE id = $1`, workspaceID,
|
||||
).Scan(&rt); err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("llm_billing_mode: read runtime for %s: %v (deriving with empty runtime)", workspaceID, err)
|
||||
}
|
||||
}
|
||||
runtime = rt.String
|
||||
if runtime == "" {
|
||||
// Mirror the DB column default so an unset runtime still derives.
|
||||
runtime = "claude-code"
|
||||
}
|
||||
|
||||
// Gather model + auth-env-name keys from workspace_secrets in one pass.
|
||||
authSet := authEnvNameSet()
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&wsOverride)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Workspace row missing — concurrent delete, or pre-create call. Don't
|
||||
// silently flip; fall through to org default. Source stays org_default
|
||||
// so operators can see the row-missing case is being handled as a
|
||||
// fallback, not a workspace-explicit decision.
|
||||
res.ResolvedMode = res.OrgDefault
|
||||
res.Source = BillingModeSourceOrgDefault
|
||||
if !isKnownBillingMode(orgMode) {
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("llm_billing_mode: read secrets for %s: %v (deriving with no model/auth-env)", workspaceID, err)
|
||||
return runtime, model, availableAuthEnv
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var v []byte
|
||||
var ver int
|
||||
if rows.Scan(&k, &v, &ver) != nil {
|
||||
continue
|
||||
}
|
||||
if k == "MODEL" {
|
||||
if dec, derr := crypto.DecryptVersioned(v, ver); derr == nil {
|
||||
model = string(dec)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Only the KEY matters for auth-env disambiguation (the value is the
|
||||
// secret; we never decrypt it for this purpose). Record recognized
|
||||
// provider auth-env names.
|
||||
if _, ok := authSet[k]; ok {
|
||||
availableAuthEnv = append(availableAuthEnv, k)
|
||||
}
|
||||
return res, nil
|
||||
case err != nil:
|
||||
// DB error — default-closed to platform_managed AND propagate the
|
||||
// error so operators get a structured log line. The caller is
|
||||
// expected to log and continue with the safe default.
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
return res, fmt.Errorf("resolve workspace llm_billing_mode for %s: %w", workspaceID, err)
|
||||
}
|
||||
return runtime, model, availableAuthEnv
|
||||
}
|
||||
|
||||
if wsOverride.Valid && isKnownBillingMode(wsOverride.String) {
|
||||
mode := wsOverride.String
|
||||
res.WorkspaceOverride = &mode
|
||||
res.ResolvedMode = mode
|
||||
res.Source = BillingModeSourceWorkspaceOverride
|
||||
return res, nil
|
||||
}
|
||||
// authEnvNameSet is the union of every provider's auth_env names in the
|
||||
// registry — the recognized set readWorkspaceDeriveInputs filters secret keys
|
||||
// against. Loaded once from the registry so it stays in sync with the SSOT (no
|
||||
// hardcoded auth-env vocabulary). Registry-load failure yields an empty set
|
||||
// (derive then runs without the auth-env tie-break, which only matters for the
|
||||
// oauth-vs-api overlap; safe — it errors to default-closed rather than guessing).
|
||||
var (
|
||||
authEnvNameSetOnce sync.Once
|
||||
authEnvNameSetVal map[string]struct{}
|
||||
)
|
||||
|
||||
// Override row present but the value is NULL or garbled. Fall through.
|
||||
// If the value was non-NULL but garbled (CHECK constraint should prevent
|
||||
// this, but defense in depth — a future migration could relax the check
|
||||
// or another path could write the column directly), surface the raw
|
||||
// override value so operators can spot the corrupt row.
|
||||
if wsOverride.Valid {
|
||||
raw := wsOverride.String
|
||||
res.WorkspaceOverride = &raw
|
||||
func authEnvNameSet() map[string]struct{} {
|
||||
authEnvNameSetOnce.Do(func() {
|
||||
authEnvNameSetVal = map[string]struct{}{}
|
||||
m, err := providerRegistry()
|
||||
if err != nil || m == nil {
|
||||
return
|
||||
}
|
||||
for _, p := range m.Providers {
|
||||
for _, e := range p.AuthEnv {
|
||||
authEnvNameSetVal[e] = struct{}{}
|
||||
}
|
||||
}
|
||||
})
|
||||
return authEnvNameSetVal
|
||||
}
|
||||
|
||||
// availableAuthEnvNames returns the recognized provider auth-env-var NAMES
|
||||
// present (non-empty) in envVars — the DeriveProvider auth-env tie-break input.
|
||||
// Never returns secret VALUES, only the env-var names. Used by the provision
|
||||
// path (applyPlatformManagedLLMEnv), which already has the workspace env in
|
||||
// hand, so it derives without a secrets DB round-trip.
|
||||
func availableAuthEnvNames(envVars map[string]string) []string {
|
||||
authSet := authEnvNameSet()
|
||||
var out []string
|
||||
for k, v := range envVars {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := authSet[k]; ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
res.ResolvedMode = res.OrgDefault
|
||||
res.Source = BillingModeSourceOrgDefault
|
||||
if !isKnownBillingMode(orgMode) {
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
return out
|
||||
}
|
||||
|
||||
// derefOrEmpty returns the pointed-to string or "" for a nil pointer. Used in
|
||||
// log lines that surface an optional *string field.
|
||||
func derefOrEmpty(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return res, nil
|
||||
return *s
|
||||
}
|
||||
|
||||
// SetWorkspaceLLMBillingMode writes the override column. Pass mode=="" to
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
// llm_billing_mode_derived_test.go — tests for the DERIVED billing-mode
|
||||
// resolver (internal#718 P2-B). The platform-vs-byok decision now DERIVES the
|
||||
// provider from (runtime, model) via the provider registry and keys off
|
||||
// IsPlatform(derived) — it does NOT read a stored LLM_PROVIDER (supersedes
|
||||
// #1966's stored-read approach) and does NOT read the org rung (retired,
|
||||
// CTO 2026-05-27). `workspaces.llm_billing_mode` survives ONLY as an optional
|
||||
// explicit operator override (first precedence).
|
||||
//
|
||||
// This file pins the explicit BEHAVIOR DELTA the RFC's P2 calls out:
|
||||
// - platform-derived (or unset → platform default) → platform_managed (UNCHANGED)
|
||||
// - non-platform-derived → byok (THE FIX — the Reno leak class)
|
||||
// - explicit override → wins over derive
|
||||
// - derive error / unregistered → platform_managed (default-closed)
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
// expectOverrideQuery sets up the workspaces.llm_billing_mode override read
|
||||
// (first precedence). value=="" means NULL (no override).
|
||||
func expectOverrideQuery(m sqlmock.Sqlmock, wsID, value string) {
|
||||
rows := sqlmock.NewRows([]string{"llm_billing_mode"})
|
||||
if value == "" {
|
||||
rows.AddRow(nil)
|
||||
} else {
|
||||
rows.AddRow(value)
|
||||
}
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
}
|
||||
|
||||
func TestResolveLLMBillingModeDerived_BehaviorDelta(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const wsID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
runtime string
|
||||
model string
|
||||
authEnv []string
|
||||
override string // "" = NULL override (no explicit operator override)
|
||||
wantMode string
|
||||
wantSource BillingModeSource
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
{
|
||||
// PLATFORM-DERIVED → platform_managed (UNCHANGED). claude-code +
|
||||
// a platform-namespaced model id derives to the closed `platform`
|
||||
// provider → IsPlatform → platform_managed.
|
||||
name: "platform_derived_keeps_platform_managed_UNCHANGED",
|
||||
runtime: "claude-code",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
override: "",
|
||||
wantMode: LLMBillingModePlatformManaged,
|
||||
wantSource: BillingModeSourceDerivedProvider,
|
||||
},
|
||||
{
|
||||
// NON-PLATFORM-DERIVED → byok (THE FIX). claude-code + the
|
||||
// kimi-coding-native model derives to the non-platform kimi-coding
|
||||
// provider → IsPlatform=false → byok. This is the Reno billing-leak
|
||||
// class: pre-P2 it resolved platform_managed and ran on platform creds.
|
||||
name: "non_platform_derived_resolves_byok_THE_FIX",
|
||||
runtime: "claude-code",
|
||||
model: "kimi-for-coding",
|
||||
override: "",
|
||||
wantMode: LLMBillingModeBYOK,
|
||||
wantSource: BillingModeSourceDerivedProvider,
|
||||
},
|
||||
{
|
||||
// NON-PLATFORM vendor on codex: gpt-5.5 derives to `openai` (BYOK).
|
||||
name: "non_platform_openai_codex_byok",
|
||||
runtime: "codex",
|
||||
model: "gpt-5.5",
|
||||
override: "",
|
||||
wantMode: LLMBillingModeBYOK,
|
||||
wantSource: BillingModeSourceDerivedProvider,
|
||||
},
|
||||
{
|
||||
// PLATFORM-DERIVED on codex: openai/gpt-5.4 is platform-namespaced.
|
||||
name: "platform_derived_codex_platform_managed",
|
||||
runtime: "codex",
|
||||
model: "openai/gpt-5.4",
|
||||
override: "",
|
||||
wantMode: LLMBillingModePlatformManaged,
|
||||
wantSource: BillingModeSourceDerivedProvider,
|
||||
},
|
||||
{
|
||||
// UNSET model → platform default (CTO-confirmed "unset → platform
|
||||
// default"). No model means nothing to derive; default-closed.
|
||||
name: "unset_model_platform_default",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
override: "",
|
||||
wantMode: LLMBillingModePlatformManaged,
|
||||
wantSource: BillingModeSourceDerivedDefault,
|
||||
},
|
||||
{
|
||||
// UNREGISTERED model → derive errors → platform default (default-closed,
|
||||
// NOT a silent byok flip that would strip a workspace's creds).
|
||||
name: "unregistered_model_derive_error_platform_default",
|
||||
runtime: "claude-code",
|
||||
model: "totally-made-up-model-xyz",
|
||||
override: "",
|
||||
wantMode: LLMBillingModePlatformManaged,
|
||||
wantSource: BillingModeSourceDerivedDefault,
|
||||
},
|
||||
{
|
||||
// UNKNOWN runtime → derive errors → platform default (default-closed).
|
||||
name: "unknown_runtime_platform_default",
|
||||
runtime: "no-such-runtime",
|
||||
model: "claude-opus-4-7",
|
||||
override: "",
|
||||
wantMode: LLMBillingModePlatformManaged,
|
||||
wantSource: BillingModeSourceDerivedDefault,
|
||||
},
|
||||
{
|
||||
// EXPLICIT OVERRIDE wins over derive: a non-platform-deriving model
|
||||
// kept on platform_managed by an operator override (escape hatch).
|
||||
name: "explicit_override_platform_managed_wins_over_byok_derive",
|
||||
runtime: "claude-code",
|
||||
model: "kimi-for-coding", // would derive byok
|
||||
override: LLMBillingModePlatformManaged,
|
||||
wantMode: LLMBillingModePlatformManaged,
|
||||
wantSource: BillingModeSourceWorkspaceOverride,
|
||||
},
|
||||
{
|
||||
// EXPLICIT OVERRIDE byok wins over a platform-deriving model.
|
||||
name: "explicit_override_byok_wins_over_platform_derive",
|
||||
runtime: "claude-code",
|
||||
model: "anthropic/claude-opus-4-7", // would derive platform_managed
|
||||
override: LLMBillingModeBYOK,
|
||||
wantMode: LLMBillingModeBYOK,
|
||||
wantSource: BillingModeSourceWorkspaceOverride,
|
||||
},
|
||||
{
|
||||
// EXPLICIT OVERRIDE disabled wins (no-LLM workspace).
|
||||
name: "explicit_override_disabled_wins",
|
||||
runtime: "claude-code",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
override: LLMBillingModeDisabled,
|
||||
wantMode: LLMBillingModeDisabled,
|
||||
wantSource: BillingModeSourceWorkspaceOverride,
|
||||
},
|
||||
{
|
||||
// AUTH-ENV disambiguation: claude-code's anthropic-oauth (alias
|
||||
// model "opus") vs anthropic-api both could match a bare alias; with
|
||||
// CLAUDE_CODE_OAUTH_TOKEN present it derives anthropic-oauth → byok.
|
||||
name: "auth_env_disambiguates_oauth_byok",
|
||||
runtime: "claude-code",
|
||||
model: "opus",
|
||||
authEnv: []string{"CLAUDE_CODE_OAUTH_TOKEN"},
|
||||
override: "",
|
||||
wantMode: LLMBillingModeBYOK,
|
||||
wantSource: BillingModeSourceDerivedProvider,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
expectOverrideQuery(mock, wsID, c.override)
|
||||
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, wsID, c.runtime, c.model, c.authEnv)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("err: got %v wantErr=%v", err, c.wantErr)
|
||||
}
|
||||
if res.ResolvedMode != c.wantMode {
|
||||
t.Errorf("mode: got %q want %q", res.ResolvedMode, c.wantMode)
|
||||
}
|
||||
if res.Source != c.wantSource {
|
||||
t.Errorf("source: got %q want %q", res.Source, c.wantSource)
|
||||
}
|
||||
if !isKnownBillingMode(res.ResolvedMode) {
|
||||
t.Errorf("post-condition: resolved mode %q not a known enum", res.ResolvedMode)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLLMBillingModeDerived_OverrideDBError_DefaultClosed asserts a DB
|
||||
// error reading the override column defaults closed to platform_managed and
|
||||
// propagates the error — never silently flips a workspace off platform creds.
|
||||
func TestResolveLLMBillingModeDerived_OverrideDBError_DefaultClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const wsID = "44444444-4444-4444-4444-444444444444"
|
||||
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, wsID, "claude-code", "kimi-for-coding", nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected propagated DB error, got nil")
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("default-closed: DB error must resolve platform_managed, got %q", res.ResolvedMode)
|
||||
}
|
||||
if res.Source != BillingModeSourceConstantFallback {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceConstantFallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLLMBillingModeDerived_EmptyWorkspaceID_PlatformDefault asserts the
|
||||
// pre-provision context (no workspace id, no override read) defaults to
|
||||
// platform_managed without a DB query.
|
||||
func TestResolveLLMBillingModeDerived_EmptyWorkspaceID_PlatformDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mock := setupTestDB(t) // no query expected
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, "", "claude-code", "kimi-for-coding", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("empty workspace id must default platform_managed, got %q", res.ResolvedMode)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,12 @@ import (
|
||||
|
||||
// GetWorkspaceLLMBillingMode handles GET /admin/workspaces/:id/llm-billing-mode.
|
||||
//
|
||||
// Reads the workspace override + the org-level default (from the same
|
||||
// MOLECULE_LLM_BILLING_MODE env var the provisioner reads at strip-gate time —
|
||||
// keeps the two paths consistent so the GET result matches what the strip
|
||||
// gate would compute) and returns the structured resolution.
|
||||
// internal#718 P2-B: the resolution now DERIVES the provider from the
|
||||
// workspace's stored (runtime, model) via the registry (org rung retired). The
|
||||
// passed orgMode is ignored by the resolver; it is left here only to avoid
|
||||
// churning the call signature. The returned resolution matches what the
|
||||
// provision-time strip gate computes (same derived resolver), so operators see
|
||||
// the real platform-vs-byok decision + the derived provider in ProviderSelection.
|
||||
func GetWorkspaceLLMBillingMode(c *gin.Context) {
|
||||
workspaceID := strings.TrimSpace(c.Param("id"))
|
||||
if !uuidRegex.MatchString(workspaceID) {
|
||||
|
||||
@@ -29,13 +29,42 @@ func init() {
|
||||
|
||||
const testWSID = "44444444-4444-4444-4444-444444444444"
|
||||
|
||||
func TestGetWorkspaceLLMBillingMode_HappyPath_InheritsOrgDefault(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModeBYOK)
|
||||
// expectDeriveShimQueries sets up the three reads the legacy-signature
|
||||
// ResolveLLMBillingMode shim makes on a no-explicit-override path
|
||||
// (internal#718 P2-B): the override read (NULL here), the workspaces.runtime
|
||||
// read, and the workspace_secrets scan (for MODEL + auth-env names). model==""
|
||||
// means no MODEL secret row.
|
||||
func expectDeriveShimQueries(m sqlmock.Sqlmock, wsID, runtime, model string) {
|
||||
nullOverride := func() {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
}
|
||||
// Order: override(NULL) shim check, runtime, secrets, override(NULL) again
|
||||
// (the derived resolver re-checks the override as a complete SSOT).
|
||||
nullOverride()
|
||||
m.ExpectQuery(`SELECT runtime FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow(runtime))
|
||||
secretRows := sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"})
|
||||
if model != "" {
|
||||
// encryption_version 0 = plaintext passthrough (crypto.DecryptVersioned).
|
||||
secretRows.AddRow("MODEL", []byte(model), 0)
|
||||
}
|
||||
m.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(secretRows)
|
||||
nullOverride()
|
||||
}
|
||||
|
||||
// internal#718 P2-B: org rung retired. A no-override workspace's mode is now
|
||||
// DERIVED from its stored (runtime, model). A claude-code workspace with a
|
||||
// non-platform-deriving model (kimi-for-coding) resolves byok via
|
||||
// derived_provider — NOT the old "inherit org default".
|
||||
func TestGetWorkspaceLLMBillingMode_HappyPath_DerivesByokFromModel(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModeBYOK) // org env ignored now
|
||||
mock := setupTestDB(t)
|
||||
// Workspace has no override → resolver returns org_default = byok.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(testWSID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
expectDeriveShimQueries(mock, testWSID, "claude-code", "kimi-for-coding")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -54,12 +83,15 @@ func TestGetWorkspaceLLMBillingMode_HappyPath_InheritsOrgDefault(t *testing.T) {
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Errorf("resolved mode: got %q want %q", res.ResolvedMode, LLMBillingModeBYOK)
|
||||
}
|
||||
if res.Source != BillingModeSourceOrgDefault {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceOrgDefault)
|
||||
if res.Source != BillingModeSourceDerivedProvider {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceDerivedProvider)
|
||||
}
|
||||
if res.WorkspaceOverride != nil {
|
||||
t.Errorf("expected nil override, got %v", *res.WorkspaceOverride)
|
||||
}
|
||||
if res.ProviderSelection == nil || *res.ProviderSelection != "kimi-coding" {
|
||||
t.Errorf("expected derived provider kimi-coding, got %v", res.ProviderSelection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWorkspaceLLMBillingMode_BadUUID_400(t *testing.T) {
|
||||
@@ -117,9 +149,9 @@ func TestPutWorkspaceLLMBillingMode_ExplicitNullClearsOverride(t *testing.T) {
|
||||
mock.ExpectExec(`UPDATE workspaces SET llm_billing_mode = NULL WHERE id = \$1`).
|
||||
WithArgs(testWSID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(testWSID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
// After clear, the post-write re-resolution DERIVES (internal#718 P2-B):
|
||||
// no override + no MODEL secret → derived_default → platform_managed.
|
||||
expectDeriveShimQueries(mock, testWSID, "claude-code", "")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -142,8 +174,8 @@ func TestPutWorkspaceLLMBillingMode_ExplicitNullClearsOverride(t *testing.T) {
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("post-clear resolved: got %q want %q", res.ResolvedMode, LLMBillingModePlatformManaged)
|
||||
}
|
||||
if res.Source != BillingModeSourceOrgDefault {
|
||||
t.Errorf("post-clear source: got %q want %q", res.Source, BillingModeSourceOrgDefault)
|
||||
if res.Source != BillingModeSourceDerivedDefault {
|
||||
t.Errorf("post-clear source: got %q want %q", res.Source, BillingModeSourceDerivedDefault)
|
||||
}
|
||||
if res.WorkspaceOverride != nil {
|
||||
t.Errorf("post-clear override should be nil, got %v", *res.WorkspaceOverride)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package handlers
|
||||
|
||||
// llm_billing_mode_test.go — table-driven tests for the per-workspace
|
||||
// resolver (internal#691). The cases below enumerate every documented
|
||||
// branch in the default-closed contract; if one of them flips behavior
|
||||
// later the test names will tell the reviewer exactly which RFC clause
|
||||
// regressed.
|
||||
// llm_billing_mode_test.go — tests for the LEGACY-signature resolver
|
||||
// ResolveLLMBillingMode after internal#718 P2-B. The org rung is RETIRED: the
|
||||
// legacy shim now reads the explicit override first, then DERIVES the provider
|
||||
// from the workspace's stored (runtime, model) via the registry (no org
|
||||
// default). The dedicated derived-resolver cases live in
|
||||
// llm_billing_mode_derived_test.go; this file pins the legacy shim's DB-read
|
||||
// sequence + that it routes through the derived semantics.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,35 +16,56 @@ import (
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
// expectLegacyShimQueries sets up the DB reads the legacy ResolveLLMBillingMode
|
||||
// shim makes on a NO-explicit-override path (internal#718 P2-B), in order:
|
||||
// 1. override read (NULL) — the shim's own precedence-1 check,
|
||||
// 2. workspaces.runtime read,
|
||||
// 3. workspace_secrets scan (MODEL + auth-env names),
|
||||
// 4. override read AGAIN (NULL) — the derived resolver re-checks it so it is a
|
||||
// complete, independently-callable SSOT.
|
||||
//
|
||||
// model=="" means no MODEL secret row.
|
||||
func expectLegacyShimQueries(m sqlmock.Sqlmock, wsID, runtime, model string) {
|
||||
nullOverride := func() {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
}
|
||||
nullOverride()
|
||||
m.ExpectQuery(`SELECT runtime FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow(runtime))
|
||||
secretRows := sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"})
|
||||
if model != "" {
|
||||
secretRows.AddRow("MODEL", []byte(model), 0) // version 0 = plaintext
|
||||
}
|
||||
m.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(secretRows)
|
||||
nullOverride()
|
||||
}
|
||||
|
||||
func TestResolveLLMBillingMode_LegacyShimDerives(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const wsID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
type want struct {
|
||||
mode string
|
||||
source BillingModeSource
|
||||
// hasOverride asserts whether the resolver surfaced the override
|
||||
// value in the result (nil pointer = clean inherit, non-nil = the
|
||||
// row was present even if it ultimately fell through because it
|
||||
// was garbled). Lets us distinguish "row missing, fell through"
|
||||
// from "row present but garbled, fell through" — both resolve to
|
||||
// the same mode but the resolver tells operators which case it was.
|
||||
mode string
|
||||
source BillingModeSource
|
||||
hasOverride bool
|
||||
}
|
||||
type tc struct {
|
||||
name string
|
||||
workspaceID string
|
||||
orgMode string
|
||||
setupMock func(m sqlmock.Sqlmock)
|
||||
want want
|
||||
wantErr bool
|
||||
name string
|
||||
setupMock func(m sqlmock.Sqlmock)
|
||||
want want
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
{
|
||||
name: "workspace_override_byok_overrides_pm_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
// Explicit override still wins (first precedence; only stored signal
|
||||
// that survives P2-B). No runtime/secrets read needed.
|
||||
name: "explicit_override_byok_wins",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
@@ -51,106 +74,60 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceWorkspaceOverride, hasOverride: true},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_disabled_overrides_pm_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
// No override + a non-platform-deriving model → byok via derive (THE
|
||||
// FIX: pre-P2 this was platform_managed via the org rung).
|
||||
name: "no_override_derives_byok_from_model",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeDisabled))
|
||||
expectLegacyShimQueries(m, wsID, "claude-code", "kimi-for-coding")
|
||||
},
|
||||
want: want{mode: LLMBillingModeDisabled, source: BillingModeSourceWorkspaceOverride, hasOverride: true},
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceDerivedProvider, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_null_inherits_byok_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModeBYOK,
|
||||
// No override + a platform-namespaced model → platform_managed (UNCHANGED).
|
||||
name: "no_override_derives_platform_from_model",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
expectLegacyShimQueries(m, wsID, "claude-code", "anthropic/claude-opus-4-7")
|
||||
},
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceDerivedProvider, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_null_inherits_pm_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
// No override + no model → derived_default → platform_managed (unset → platform).
|
||||
name: "no_override_no_model_platform_default",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
expectLegacyShimQueries(m, wsID, "claude-code", "")
|
||||
},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceDerivedDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_garbled_falls_through_to_pm_org_DEFAULT_CLOSED",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
// Garbled override is NOT honored — falls through to derive
|
||||
// (default-closed). Here no model → platform default.
|
||||
name: "garbled_override_falls_through_to_derive_default_closed",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
// CHECK constraint would normally prevent this but if a future
|
||||
// migration loosens it (or a direct UPDATE bypasses it on a
|
||||
// non-PG driver in a test stub), a garbled value MUST NOT
|
||||
// be honored as if it were valid. This is the default-closed
|
||||
// safety axis the RFC calls out.
|
||||
// override read 1 (garbled → not honored), runtime, secrets,
|
||||
// override read 2 (garbled again, derived resolver re-check).
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("byokk"))
|
||||
m.ExpectQuery(`SELECT runtime FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
m.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("byokk"))
|
||||
},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceOrgDefault, hasOverride: true},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceDerivedDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_garbled_org_garbled_constant_fallback",
|
||||
workspaceID: wsID,
|
||||
orgMode: "garbled-or-empty",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("nonsense"))
|
||||
},
|
||||
// Both layers garbled → constant fallback. Source is constant_fallback
|
||||
// so operators can see the org-default-was-also-bad case explicitly.
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: true},
|
||||
},
|
||||
{
|
||||
name: "workspace_row_missing_falls_through_to_org_byok",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModeBYOK,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}))
|
||||
},
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_id_empty_pre_provision_org_only",
|
||||
workspaceID: "",
|
||||
orgMode: LLMBillingModeBYOK,
|
||||
setupMock: func(m sqlmock.Sqlmock) { /* no DB read expected — empty ws id short-circuits */ },
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_id_empty_org_garbled_constant_fallback",
|
||||
workspaceID: "",
|
||||
orgMode: "",
|
||||
setupMock: func(m sqlmock.Sqlmock) { /* no DB read */ },
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "db_error_default_closed_to_pm_with_error",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModeBYOK, // org says byok but DB errored — DO NOT honor org
|
||||
// DB error on the override read → default-closed + propagated error.
|
||||
name: "override_db_error_default_closed_with_error",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
},
|
||||
// Critical: even though orgMode=byok, a DB error means we can't
|
||||
// confirm the workspace doesn't have an override, so we default
|
||||
// to the closed mode. This is the safer of the two failures —
|
||||
// silently flipping to org-byok on a DB error would leak the
|
||||
// OAuth-keeping behavior to workspaces whose row says NULL.
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: false},
|
||||
wantErr: true,
|
||||
},
|
||||
@@ -161,7 +138,8 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
c.setupMock(mock)
|
||||
|
||||
res, err := ResolveLLMBillingMode(ctx, c.workspaceID, c.orgMode)
|
||||
// orgMode arg is retired/ignored; pass a value to prove it has no effect.
|
||||
res, err := ResolveLLMBillingMode(ctx, wsID, LLMBillingModeBYOK)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("err: got %v wantErr=%v", err, c.wantErr)
|
||||
}
|
||||
@@ -172,8 +150,7 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
t.Errorf("source: got %q want %q", res.Source, c.want.source)
|
||||
}
|
||||
if (res.WorkspaceOverride != nil) != c.want.hasOverride {
|
||||
t.Errorf("hasOverride: got %v want %v (override=%v)",
|
||||
res.WorkspaceOverride != nil, c.want.hasOverride, res.WorkspaceOverride)
|
||||
t.Errorf("hasOverride: got %v want %v", res.WorkspaceOverride != nil, c.want.hasOverride)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
@@ -182,21 +159,48 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLLMBillingMode_EmptyWorkspaceID_PlatformDefault: pre-provision
|
||||
// (no workspace id) defaults closed with no DB read (org rung retired, so the
|
||||
// old "org_only" behavior is gone — it's now the platform default).
|
||||
func TestResolveLLMBillingMode_EmptyWorkspaceID_PlatformDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mock := setupTestDB(t) // no DB read expected
|
||||
res, err := ResolveLLMBillingMode(ctx, "", LLMBillingModeBYOK)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("empty ws id must default platform_managed, got %q", res.ResolvedMode)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLLMBillingMode_ResolvedModeIsAlwaysValid asserts the resolver's
|
||||
// post-condition: the returned mode is ALWAYS one of the three known enum
|
||||
// values, never an empty string and never a garbled passthrough. The strip
|
||||
// gate downstream relies on this so it can switch on res.ResolvedMode
|
||||
// without a separate is-valid check on every call site.
|
||||
// values. The strip gate downstream relies on this so it can switch on
|
||||
// res.ResolvedMode without a separate is-valid check on every call site.
|
||||
func TestResolveLLMBillingMode_ResolvedModeIsAlwaysValid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const wsID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
// Throw a pathological row at the resolver: garbled override + garbled
|
||||
// org default. Resolved mode must still be a recognized enum.
|
||||
// Garbled override + no derivable model: must still resolve a known enum
|
||||
// (platform_managed, default-closed). Query order: override(garbled),
|
||||
// runtime, secrets, override(garbled again — derived resolver re-check).
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("totally-bogus"))
|
||||
mock.ExpectQuery(`SELECT runtime FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("totally-bogus"))
|
||||
|
||||
res, err := ResolveLLMBillingMode(ctx, wsID, "also-bogus")
|
||||
if err != nil {
|
||||
@@ -206,7 +210,7 @@ func TestResolveLLMBillingMode_ResolvedModeIsAlwaysValid(t *testing.T) {
|
||||
t.Errorf("post-condition violated: resolved mode %q is not a known enum value", res.ResolvedMode)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("default-closed contract: garbled-x-garbled must resolve to platform_managed, got %q", res.ResolvedMode)
|
||||
t.Errorf("default-closed contract: garbled-override + no-model must resolve platform_managed, got %q", res.ResolvedMode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
// model_registry_validation.go — only-registered (runtime, model) validation
|
||||
// at the create/config API (internal#718 P2-B item 3, CTO 2026-05-27
|
||||
// "only registered providers/models selectable").
|
||||
//
|
||||
// The registry (internal/providers) is the SSOT for which models a runtime
|
||||
// natively exposes (ModelsForRuntime). This validator rejects a (runtime, model)
|
||||
// the registry does NOT recognize — but ONLY for a runtime the registry knows
|
||||
// about. For a runtime absent from the first-party registry (langgraph,
|
||||
// external, kimi, mock, or a future federated third-party runtime), it fails
|
||||
// OPEN: the registry can't speak to that runtime's model set, so the existing
|
||||
// knownRuntimes gate stays authoritative and this validator does not block.
|
||||
// This is the federation-ready contract — first-party runtimes are gated against
|
||||
// the registry; everything else passes through unchanged (no behavior change for
|
||||
// non-registry runtimes).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateRegisteredModelForRuntime reports whether (runtime, model) is
|
||||
// selectable per the provider registry. Returns:
|
||||
//
|
||||
// (true, "") — allowed: model is registered for this runtime, OR the
|
||||
// runtime is not in the registry (fail-open), OR model=="".
|
||||
// (false, reason) — rejected: the runtime IS registered but the model is not
|
||||
// in its native ModelsForRuntime set.
|
||||
//
|
||||
// model=="" is allowed here: the MODEL_REQUIRED gate owns the empty-model case,
|
||||
// so this validator must not double-reject it.
|
||||
func validateRegisteredModelForRuntime(runtime, model string) (bool, string) {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return true, "" // MODEL_REQUIRED owns this.
|
||||
}
|
||||
m, err := providerRegistry()
|
||||
if err != nil || m == nil {
|
||||
// Registry unavailable (build-time defect the gates catch). Fail open —
|
||||
// do not block create on a registry-load failure.
|
||||
return true, ""
|
||||
}
|
||||
models, err := m.ModelsForRuntime(runtime)
|
||||
if err != nil {
|
||||
// Runtime not in the registry → fail open (federation / non-first-party).
|
||||
return true, ""
|
||||
}
|
||||
for _, mid := range models {
|
||||
if mid == model {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, fmt.Sprintf(
|
||||
"model %q is not a registered model for runtime %q; pick one of the runtime's registered models (provider-registry SSOT, internal#718)",
|
||||
model, runtime)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
// model_registry_validation_test.go — only-registered (runtime, model)
|
||||
// validation at the create/config API (internal#718 P2-B item 3). Reject a
|
||||
// (runtime, model) the registry does not recognize for a runtime it DOES know;
|
||||
// fail OPEN (allow) for a runtime the registry doesn't know yet (federation /
|
||||
// langgraph/etc. not in the first-party registry) so the existing knownRuntimes
|
||||
// gate stays authoritative there.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateRegisteredModelForRuntime(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
runtime string
|
||||
model string
|
||||
wantOK bool // true = allowed (registered OR runtime-not-in-registry)
|
||||
}
|
||||
cases := []tc{
|
||||
{
|
||||
name: "registered_platform_model_allowed",
|
||||
runtime: "claude-code",
|
||||
model: "anthropic/claude-opus-4-7",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "registered_byok_model_allowed",
|
||||
runtime: "claude-code",
|
||||
model: "kimi-for-coding",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "registered_codex_model_allowed",
|
||||
runtime: "codex",
|
||||
model: "gpt-5.5",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "unregistered_model_for_known_runtime_rejected",
|
||||
runtime: "claude-code",
|
||||
model: "totally-made-up-model-xyz",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "wrong_runtime_for_model_rejected",
|
||||
runtime: "codex",
|
||||
model: "kimi-for-coding", // claude-code's, not codex's
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// langgraph is a real core runtime but NOT in the first-party
|
||||
// registry → fail OPEN (the registry can't speak to it yet).
|
||||
name: "runtime_not_in_registry_allowed_failopen",
|
||||
runtime: "langgraph",
|
||||
model: "anything-goes",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// external/kimi/mock runtimes are not in the registry → fail open.
|
||||
name: "external_runtime_allowed_failopen",
|
||||
runtime: "external",
|
||||
model: "whatever",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// empty model → not this gate's job (MODEL_REQUIRED handles it);
|
||||
// allow so we don't double-reject.
|
||||
name: "empty_model_allowed_other_gate_owns_it",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
ok, _ := validateRegisteredModelForRuntime(c.runtime, c.model)
|
||||
if ok != c.wantOK {
|
||||
t.Errorf("validateRegisteredModelForRuntime(%q,%q) ok=%v want %v", c.runtime, c.model, ok, c.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,33 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// internal#718 P2-B: ONLY-REGISTERED validation at the create boundary.
|
||||
// For a runtime the provider registry knows (first-party:
|
||||
// claude-code/codex/hermes/openclaw) this checks the (runtime, model) pair
|
||||
// against the registry's native model set. Fails OPEN for runtimes the
|
||||
// registry doesn't know (langgraph/external/kimi/mock/federated) so
|
||||
// non-first-party flows are UNCHANGED. Skipped for external workspaces.
|
||||
//
|
||||
// P2 ENFORCEMENT MODE = WARN, not hard-reject (deliberate, scoped). The
|
||||
// legacy colon-namespaced BYOK model vocabulary ("anthropic:claude-opus-4-7"
|
||||
// etc.) is still live across the create/import/template corpus and is NOT
|
||||
// yet reconciled into the registry's exact-id model sets — that convergence
|
||||
// is P3 (canvas only-offers-registered) + P4 (template codegen). Hard-
|
||||
// rejecting an unregistered (runtime, model) now would 422 those legitimate
|
||||
// existing flows, a large behavior change outside P2's scope (P2's behavior
|
||||
// delta is the billing/credential flip, below). So P2 surfaces the
|
||||
// unregistered pair as a queryable warning + an X-Molecule-Model-Unregistered
|
||||
// response header (operator/canvas signal) and lets create proceed; the gate
|
||||
// flips to hard-reject (uncomment the 422 below) once P3/P4 land the
|
||||
// vocabulary convergence. The registry model set is code-generated from the
|
||||
// canonical providers.yaml (PR-A), so the check stays in sync with the SSOT.
|
||||
if !isExternal {
|
||||
if ok, why := validateRegisteredModelForRuntime(payload.Runtime, payload.Model); !ok {
|
||||
log.Printf("Create: WARN unregistered model (runtime=%q model=%q): %s [internal#718 P2 warn-mode; hard-reject gated on P3/P4 vocabulary convergence]", payload.Runtime, payload.Model, why)
|
||||
c.Header("X-Molecule-Model-Unregistered", "true")
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Convert empty role to NULL
|
||||
|
||||
@@ -981,18 +981,32 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
type platformLLMEnvResult struct {
|
||||
ResolvedMode string
|
||||
HasUsableLLMCred bool
|
||||
// Source records which layer decided the mode (internal#718 P2-B):
|
||||
// derived_provider (registry derivation), derived_default (derive failed →
|
||||
// platform default), workspace_override (explicit operator pin), or
|
||||
// constant_fallback (DB error). Surfaced for observability + asserted by the
|
||||
// behavior-delta tests so a regression of "derived, not stored" flips red.
|
||||
Source BillingModeSource
|
||||
}
|
||||
|
||||
func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string, globalKeys map[string]struct{}, workspaceID, runtime, model string) platformLLMEnvResult {
|
||||
orgMode := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE")))
|
||||
res, resolveErr := ResolveLLMBillingMode(ctx, workspaceID, orgMode)
|
||||
// internal#718 P2-B: the platform-vs-byok decision now DERIVES the provider
|
||||
// from (runtime, model) via the registry and keys off IsPlatform(derived) —
|
||||
// NOT a stored LLM_PROVIDER and NOT the org rung. This path already carries
|
||||
// runtime + model + the workspace env, so it calls the DERIVED resolver
|
||||
// directly (no DB round-trip for runtime/model). availableAuthEnv is the set
|
||||
// of recognized provider auth-env-var NAMES present in envVars (the same
|
||||
// disambiguation input the registry uses to split oauth-vs-api). The org-env
|
||||
// MOLECULE_LLM_BILLING_MODE is NO LONGER read into the decision (retired).
|
||||
availableAuthEnv := availableAuthEnvNames(envVars)
|
||||
res, resolveErr := ResolveLLMBillingModeDerived(ctx, workspaceID, runtime, model, availableAuthEnv)
|
||||
if resolveErr != nil {
|
||||
// resolveErr != nil ⇒ resolver hit a DB error AND already defaulted
|
||||
// res.ResolvedMode to platform_managed. Log + proceed; the safe default
|
||||
// is already in place, no early return needed.
|
||||
log.Printf("workspace_provision: resolve billing mode workspace=%s err=%v (defaulting to platform_managed)", workspaceID, resolveErr)
|
||||
}
|
||||
log.Printf("workspace_provision: billing mode workspace=%s resolved=%s source=%s org_default=%s", workspaceID, res.ResolvedMode, res.Source, res.OrgDefault)
|
||||
log.Printf("workspace_provision: billing mode workspace=%s resolved=%s source=%s derived_provider=%s", workspaceID, res.ResolvedMode, res.Source, derefOrEmpty(res.ProviderSelection))
|
||||
// internal#703: MOLECULE_LLM_BILLING_MODE in the container must reflect the
|
||||
// RESOLVED per-workspace mode, not a hardcoded literal. Pre-fix this var was
|
||||
// only emitted (hardcoded "platform_managed") on the strip path below, so a
|
||||
@@ -1023,6 +1037,7 @@ func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string,
|
||||
return platformLLMEnvResult{
|
||||
ResolvedMode: res.ResolvedMode,
|
||||
HasUsableLLMCred: hasAnyPlatformManagedLLMKey(envVars),
|
||||
Source: res.Source,
|
||||
}
|
||||
}
|
||||
baseURL := firstNonEmptyEnv("MOLECULE_LLM_BASE_URL", "OPENAI_BASE_URL")
|
||||
@@ -1034,7 +1049,7 @@ func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string,
|
||||
// here — but we report HasUsableLLMCred from whatever survived so the
|
||||
// caller's fail-closed branch (non-platform only) is never reached on
|
||||
// this path.
|
||||
return platformLLMEnvResult{ResolvedMode: res.ResolvedMode, HasUsableLLMCred: true}
|
||||
return platformLLMEnvResult{ResolvedMode: res.ResolvedMode, HasUsableLLMCred: true, Source: res.Source}
|
||||
}
|
||||
stripPlatformManagedLLMBypassEnv(envVars)
|
||||
|
||||
@@ -1066,7 +1081,7 @@ func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string,
|
||||
// platform_managed: the CP proxy usage token (injected as ANTHROPIC_API_KEY
|
||||
// / OPENAI_API_KEY above) IS the usable credential, so the workspace is
|
||||
// never fail-closed on this path.
|
||||
return platformLLMEnvResult{ResolvedMode: res.ResolvedMode, HasUsableLLMCred: true}
|
||||
return platformLLMEnvResult{ResolvedMode: res.ResolvedMode, HasUsableLLMCred: true, Source: res.Source}
|
||||
}
|
||||
|
||||
func stripPlatformManagedLLMBypassEnv(envVars map[string]string) {
|
||||
|
||||
@@ -1141,20 +1141,38 @@ func TestApplyPlatformManagedLLMEnv_ClaudeCodeStripsVendorBYOK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// internal#718 P2-B: byok is now DERIVED, not org-env-driven. A claude-code
|
||||
// workspace with NO explicit override + a non-platform-deriving model
|
||||
// (kimi-for-coding → kimi-coding) resolves byok and must NOT get the CP proxy
|
||||
// creds injected. (Pre-P2 this was driven by the org env MOLECULE_LLM_BILLING_MODE
|
||||
// with an empty workspace id; that mechanism is retired.)
|
||||
func TestApplyPlatformManagedLLMEnv_NoopsOutsidePlatformManaged(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "byok")
|
||||
const wsID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
mock := setupTestDB(t)
|
||||
// No explicit override → derive from (claude-code, kimi-for-coding) → byok.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed") // org env ignored now
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, "", "claude-code", "")
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, nil, wsID, "claude-code", "kimi-for-coding")
|
||||
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Fatalf("resolved mode = %q, want byok (derived from non-platform model)", res.ResolvedMode)
|
||||
}
|
||||
if _, ok := envVars["OPENAI_API_KEY"]; ok {
|
||||
t.Fatalf("OPENAI_API_KEY should not be set outside platform-managed mode")
|
||||
}
|
||||
if _, ok := envVars["MOLECULE_LLM_USAGE_TOKEN"]; ok {
|
||||
t.Fatalf("MOLECULE_LLM_USAGE_TOKEN should not be set outside platform-managed mode")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPlatformManagedLLMEnv_ClaudeCodeByokKeepsOwnProviderEnv is the
|
||||
@@ -1274,6 +1292,136 @@ func TestApplyPlatformManagedLLMEnv_ByokStripsGlobalOriginOAuthToken(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// internal#718 P2-B BEHAVIOR DELTA — billing/credential decision DERIVES the
|
||||
// provider (no stored LLM_PROVIDER, no override). These three tests are the
|
||||
// explicit delta the RFC calls out, exercised through the real provision path
|
||||
// (applyPlatformManagedLLMEnv) with the registry derivation driving the mode:
|
||||
// - platform-derived → platform_managed → platform creds (UNCHANGED)
|
||||
// - non-platform-derived → byok → #1963 strip + fail-closed (THE FIX)
|
||||
// - unset model → platform default (CTO-confirmed)
|
||||
// All use NO explicit override (override read returns NULL) so the DERIVATION
|
||||
// is what decides — this is what supersedes #1966's stored-LLM_PROVIDER read.
|
||||
// =========================================================================
|
||||
|
||||
// PLATFORM-DERIVED → UNCHANGED. A claude-code workspace with a platform-
|
||||
// namespaced model (anthropic/claude-opus-4-7) derives to the closed `platform`
|
||||
// provider → platform_managed → CP proxy creds injected, exactly as before.
|
||||
func TestApplyPlatformManagedLLMEnv_DERIVED_PlatformModelKeepsPlatformCreds(t *testing.T) {
|
||||
const wsID = "11111111-2222-3333-4444-555555555555"
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil)) // NO override → derive
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModeBYOK) // org env IGNORED now
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "https://api.example.test/api/v1/internal/llm/anthropic")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{}
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, nil, wsID, "claude-code", "anthropic/claude-opus-4-7")
|
||||
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Fatalf("platform-derived model must resolve platform_managed, got %q (source=%s)", res.ResolvedMode, res.Source)
|
||||
}
|
||||
if res.Source != BillingModeSourceDerivedProvider {
|
||||
t.Errorf("source: got %q want derived_provider", res.Source)
|
||||
}
|
||||
// Platform path injects the CP proxy creds (UNCHANGED behavior).
|
||||
if got := envVars["ANTHROPIC_API_KEY"]; got != "tenant-admin-token" {
|
||||
t.Errorf("platform path must inject the CP proxy token as ANTHROPIC_API_KEY, got %q", got)
|
||||
}
|
||||
if !res.HasUsableLLMCred {
|
||||
t.Errorf("platform path always has a usable cred (the proxy token)")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NON-PLATFORM-DERIVED → byok + STRIP + FAIL-CLOSED signal (THE FIX, the Reno
|
||||
// billing-leak class). A claude-code workspace with a non-platform model
|
||||
// (kimi-for-coding → kimi-coding) and NO override + NO own cred, inheriting only
|
||||
// the platform's scope:global OAuth token, now DERIVES byok → #1963 strips the
|
||||
// global token → HasUsableLLMCred=false → caller fails closed. Pre-P2 this same
|
||||
// workspace resolved platform_managed (via the never-written org rung) and ran
|
||||
// on the platform's credits. This is the discriminating delta test.
|
||||
func TestApplyPlatformManagedLLMEnv_DERIVED_NonPlatformModelStripsAndFailsClosed(t *testing.T) {
|
||||
const wsID = "99999999-8888-7777-6666-555555555555"
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil)) // NO override → derive
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged) // org env IGNORED now
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
// Only LLM cred is the platform's scope:global OAuth token (globalKeys).
|
||||
envVars := map[string]string{
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "PLATFORM-GLOBAL-OAUTH-TOKEN",
|
||||
}
|
||||
globalKeys := map[string]struct{}{"CLAUDE_CODE_OAUTH_TOKEN": {}}
|
||||
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, globalKeys, wsID, "claude-code", "kimi-for-coding")
|
||||
|
||||
// 1. DERIVED byok (NOT the old platform_managed default).
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Fatalf("non-platform-derived model must resolve byok, got %q (source=%s) — THE FIX regressed", res.ResolvedMode, res.Source)
|
||||
}
|
||||
if res.Source != BillingModeSourceDerivedProvider {
|
||||
t.Errorf("source: got %q want derived_provider", res.Source)
|
||||
}
|
||||
// 2. #1963 strip: the platform global OAuth token is removed (leak closed).
|
||||
if got, ok := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; ok {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q present — must be stripped for a derived-byok workspace (Reno leak)", got)
|
||||
}
|
||||
// 3. No CP proxy creds forced.
|
||||
if got, ok := envVars["ANTHROPIC_API_KEY"]; ok {
|
||||
t.Fatalf("ANTHROPIC_API_KEY must NOT be injected for byok, got %q", got)
|
||||
}
|
||||
// 4. No usable cred → caller (prepareProvisionContext) fails closed.
|
||||
if res.HasUsableLLMCred {
|
||||
t.Fatalf("HasUsableLLMCred = true, want false (only the stripped platform global token was present)")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// UNSET model → PLATFORM DEFAULT (CTO-confirmed "unset → platform default").
|
||||
// No model means nothing to derive; the workspace defaults closed to
|
||||
// platform_managed and keeps the platform creds (UNCHANGED for the no-model case).
|
||||
func TestApplyPlatformManagedLLMEnv_DERIVED_UnsetModelPlatformDefault(t *testing.T) {
|
||||
const wsID = "00000000-1111-2222-3333-444444444444"
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil)) // NO override
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModeBYOK) // org env IGNORED now
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "https://api.example.test/api/v1/internal/llm/anthropic")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{}
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, nil, wsID, "claude-code", "")
|
||||
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Fatalf("unset model must default platform_managed, got %q (source=%s)", res.ResolvedMode, res.Source)
|
||||
}
|
||||
if res.Source != BillingModeSourceDerivedDefault {
|
||||
t.Errorf("source: got %q want derived_default", res.Source)
|
||||
}
|
||||
if got := envVars["ANTHROPIC_API_KEY"]; got != "tenant-admin-token" {
|
||||
t.Errorf("unset-model platform default must inject the CP proxy token, got %q", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPlatformManagedLLMEnv_ByokKeepsWorkspaceOwnOAuthEvenWithGlobal is
|
||||
// the discriminating companion to the strip test: a byok workspace that DID
|
||||
// set its own CLAUDE_CODE_OAUTH_TOKEN via the canvas Secrets tab (a
|
||||
|
||||
@@ -501,10 +501,12 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
// while persisting a secret causes the entire transaction to roll back and
|
||||
// the handler to return 500. The workspace row must NOT be committed.
|
||||
func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
|
||||
// internal#691: see TestExtended_SecretsSet — same default-closed reasoning.
|
||||
// This test is asserting the rollback path on DB failure, not the strip gate;
|
||||
// keep the org in byok so the OPENAI_API_KEY write reaches the INSERT.
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "byok")
|
||||
// internal#718 P2-B: this test asserts the rollback path on DB failure, not
|
||||
// the strip gate. The create-time secret gate keys off the DERIVED mode now
|
||||
// (org rung retired). An explicit byok override makes the workspace byok in a
|
||||
// single resolver read (precedence-1 short-circuit), so the OPENAI_API_KEY
|
||||
// write is allowed and reaches the INSERT-and-fail path this test exercises.
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed") // org env ignored now
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
@@ -513,14 +515,11 @@ func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// internal#691: Create() now resolves billing mode per-workspace before
|
||||
// the secret-strip gate. The workspace row was just inserted in the same
|
||||
// transaction so it isn't readable from a separate query yet; the
|
||||
// resolver expects the SELECT and the mock returns no row → falls back
|
||||
// to the org default (byok, set above) so the OPENAI_API_KEY write
|
||||
// reaches the INSERT-and-fail path this test exercises.
|
||||
// Create() resolves billing mode per-workspace before the secret-strip gate.
|
||||
// An explicit byok override short-circuits the resolver (precedence 1) so the
|
||||
// OPENAI_API_KEY write is allowed and reaches the INSERT-and-fail path.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnError(sql.ErrConnDone) // DB failure while writing secret
|
||||
mock.ExpectRollback() // workspace insert must be rolled back
|
||||
@@ -2048,6 +2047,111 @@ func TestWorkspaceCreate_188_NoTemplateNoRuntime_NowMODEL_REQUIRED(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// internal#718 P2-B: only-registered validation in P2 WARN-mode. A known
|
||||
// (registry) runtime with a model NOT in its registered set is allowed to
|
||||
// proceed (201) but flagged with the X-Molecule-Model-Unregistered response
|
||||
// header + a queryable warning log. (Hard-reject is gated on P3/P4 vocabulary
|
||||
// convergence — see the create handler comment; flipping to 422 there is a
|
||||
// one-line change once the legacy colon-form model vocabulary is reconciled.)
|
||||
func TestWorkspaceCreate_718_UnregisteredModelWarnsButProceeds(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Bad Model","runtime":"claude-code","model":"totally-made-up-xyz"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("unregistered-model create (warn-mode): expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Header().Get("X-Molecule-Model-Unregistered") != "true" {
|
||||
t.Errorf("expected X-Molecule-Model-Unregistered=true header on unregistered model, got %q", w.Header().Get("X-Molecule-Model-Unregistered"))
|
||||
}
|
||||
}
|
||||
|
||||
// A REGISTERED model on a registry runtime sets NO unregistered header.
|
||||
func TestWorkspaceCreate_718_RegisteredModelNoWarnHeader(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
// claude-opus-4-7 IS a registered claude-code model (anthropic-api).
|
||||
body := `{"name":"Good Model","runtime":"claude-code","model":"claude-opus-4-7"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("registered-model create: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Header().Get("X-Molecule-Model-Unregistered") != "" {
|
||||
t.Errorf("registered model must NOT set the unregistered header, got %q", w.Header().Get("X-Molecule-Model-Unregistered"))
|
||||
}
|
||||
}
|
||||
|
||||
// internal#718 P2-B: a runtime NOT in the registry (mock — a known core runtime
|
||||
// absent from the first-party provider registry) fails OPEN — the
|
||||
// only-registered gate does not block it (federation / non-first-party path
|
||||
// unchanged). It proceeds past the gate to the normal create flow.
|
||||
func TestWorkspaceCreate_718_NonRegistryRuntimeFailsOpen(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
// "mock" is a known core runtime but NOT in the first-party registry;
|
||||
// any model passes the only-registered gate (fail-open).
|
||||
body := `{"name":"Mock Agent","runtime":"mock","model":"canned-replies"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("non-registry runtime should fail open (201), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit runtime, no template → honored, 201 (no template resolution
|
||||
// needed; runtimeExplicitlyRequested true but already resolved).
|
||||
func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user