From b75dc1473e3689053cc3b9206ecfb6618c855d72 Mon Sep 17 00:00:00 2001 From: hongming Date: Tue, 26 May 2026 21:19:47 +0000 Subject: [PATCH] feat(canvas): LLM Billing section in Config tab (internal#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new section to the workspace Config tab that surfaces the RESOLVED per-workspace billing mode (from internal#691 resolver), the org-level default, and a dropdown to set / clear the per-workspace override. Surface: - src/components/tabs/config/llm-billing-section.tsx — the Section - src/components/tabs/config/index.ts — re-export - src/components/tabs/ConfigTab.tsx — mounts the section above SecretsSection Talks to the per-tenant workspace-server admin route GET/PUT /admin/workspaces/:id/llm-billing-mode (shipping in the workspace-server PR — feat/per-workspace-llm-billing-mode). The CP proxy at /cp/admin/workspaces/:id/llm-billing-mode exists for ops use; the canvas uses the per-tenant path directly to avoid a CP roundtrip. Dropdown semantics: - "Inherit org default" → PUT {mode: null} (clears override) - "Platform-managed" → PUT {mode: "platform_managed"} - "BYOK" → PUT {mode: "byok"} - "Disabled" → PUT {mode: "disabled"} Garbled persisted overrides surface a yellow warning banner with the raw bad value so the user can clear it explicitly. Stage A: npx vitest run src/components/tabs/config/__tests__/llm-billing-section.test.tsx → 5 tests, 5 pass Full canvas suite: 220 files, 3369 tests pass, 1 skipped (pre-existing). TS / lint clean on the new files (pre-existing main-branch TS errors in unrelated Attachment*/KeyValueField test files are not introduced here). Deploy order: this PR ships THIRD, after CP routes and the workspace-server PR — the routes the dropdown hits won't exist until workspace-server lands. Until then the section renders the error banner with the network error, which is acceptable cosmetic-only behavior. Refs internal#691. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ConfigTab.tsx | 3 + .../__tests__/llm-billing-section.test.tsx | 176 ++++++++++++++ canvas/src/components/tabs/config/index.ts | 1 + .../tabs/config/llm-billing-section.tsx | 219 ++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 canvas/src/components/tabs/config/__tests__/llm-billing-section.test.tsx create mode 100644 canvas/src/components/tabs/config/llm-billing-section.tsx diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 619cee0f5..d8a2d4f1e 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -6,6 +6,7 @@ import { useCanvasStore } from "@/store/canvas"; import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs"; import { parseYaml, toYaml } from "./config/yaml-utils"; import { SecretsSection } from "./config/secrets-section"; +import { LLMBillingSection } from "./config/llm-billing-section"; import { ExternalConnectionSection } from "./ExternalConnectionSection"; import { ProviderModelSelector, @@ -1108,6 +1109,8 @@ export function ConfigTab({ workspaceId }: Props) { + + ({ + api: { + get: (...args: unknown[]) => apiGet(...args), + put: (...args: unknown[]) => apiPut(...args), + post: vi.fn().mockResolvedValue({}), + del: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({}), + }, +})); + +// Collapsed-by-default Section wrapper would hide the content; replace +// it with a passthrough so the dropdown is reachable in the test DOM. +vi.mock("../form-inputs", async () => { + const actual = await vi.importActual( + "../form-inputs", + ); + return { + ...actual, + Section: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("LLMBillingSection — internal#691", () => { + it("renders the resolved mode + source for an inherited workspace", async () => { + apiGet.mockResolvedValueOnce({ + workspace_id: "ws-1", + resolved_mode: "platform_managed", + workspace_override: null, + org_default: "platform_managed", + source: "org_default", + }); + + render(); + + await waitFor(() => { + expect(apiGet).toHaveBeenCalledWith( + "/admin/workspaces/ws-1/llm-billing-mode", + ); + }); + // Resolved mode appears. + expect(screen.getByText(/Resolved mode:/i).textContent).toMatch(/platform_managed/); + // Source label appears. + expect( + screen.getByText(/inherited from org default/i), + ).toBeTruthy(); + }); + + it('PUTs {mode: "byok"} when user picks BYOK and reflects the new resolution', async () => { + apiGet.mockResolvedValueOnce({ + workspace_id: "ws-2", + resolved_mode: "platform_managed", + workspace_override: null, + org_default: "platform_managed", + source: "org_default", + }); + apiPut.mockResolvedValueOnce({ + workspace_id: "ws-2", + resolved_mode: "byok", + workspace_override: "byok", + org_default: "platform_managed", + source: "workspace_override", + }); + + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + + const select = (await screen.findByLabelText( + /llm billing mode override/i, + )) as HTMLSelectElement; + fireEvent.change(select, { target: { value: "byok" } }); + + await waitFor(() => { + expect(apiPut).toHaveBeenCalledWith( + "/admin/workspaces/ws-2/llm-billing-mode", + { mode: "byok" }, + ); + }); + // Post-write resolution propagated to UI. + await waitFor(() => { + expect( + screen.getByText(/explicit override on this workspace/i), + ).toBeTruthy(); + }); + }); + + it("PUTs {mode: null} when user picks Inherit (clears the override)", async () => { + apiGet.mockResolvedValueOnce({ + workspace_id: "ws-3", + resolved_mode: "byok", + workspace_override: "byok", + org_default: "platform_managed", + source: "workspace_override", + }); + apiPut.mockResolvedValueOnce({ + workspace_id: "ws-3", + resolved_mode: "platform_managed", + workspace_override: null, + org_default: "platform_managed", + source: "org_default", + }); + + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + + const select = (await screen.findByLabelText( + /llm billing mode override/i, + )) as HTMLSelectElement; + fireEvent.change(select, { target: { value: "inherit" } }); + + await waitFor(() => { + expect(apiPut).toHaveBeenCalledWith( + "/admin/workspaces/ws-3/llm-billing-mode", + { mode: null }, + ); + }); + }); + + it("surfaces a warning banner when the override value is garbled", async () => { + apiGet.mockResolvedValueOnce({ + workspace_id: "ws-4", + resolved_mode: "platform_managed", // resolver fell through, default-closed + workspace_override: "byokk", // typo persisted somehow + org_default: "platform_managed", + source: "org_default", + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/non-standard value/i), + ).toBeTruthy(); + }); + }); + + it("renders an error banner when the GET fails", async () => { + apiGet.mockRejectedValueOnce(new Error("network down")); + + render(); + + await waitFor(() => { + expect(screen.getByText(/network down/i)).toBeTruthy(); + }); + }); +}); diff --git a/canvas/src/components/tabs/config/index.ts b/canvas/src/components/tabs/config/index.ts index 6f89e3564..5515c2599 100644 --- a/canvas/src/components/tabs/config/index.ts +++ b/canvas/src/components/tabs/config/index.ts @@ -1,3 +1,4 @@ export { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./form-inputs"; export { parseYaml, toYaml } from "./yaml-utils"; export { SecretsSection } from "./secrets-section"; +export { LLMBillingSection } from "./llm-billing-section"; diff --git a/canvas/src/components/tabs/config/llm-billing-section.tsx b/canvas/src/components/tabs/config/llm-billing-section.tsx new file mode 100644 index 000000000..16498258e --- /dev/null +++ b/canvas/src/components/tabs/config/llm-billing-section.tsx @@ -0,0 +1,219 @@ +"use client"; + +// llm-billing-section.tsx — Config-tab section for the per-workspace +// llm_billing_mode override (internal#691). +// +// Surfaces: +// - The currently RESOLVED mode for this workspace (the mode the +// workspace-server's strip gate will use at next provision). +// - The org-level default (so the user sees what they're inheriting). +// - A dropdown to set / clear the workspace-level override. +// - A "source" line so operators can answer "is this inherited or +// explicit?" without DB archeology (RFC Observability hot-spot). +// +// Hits: +// GET /admin/workspaces/:id/llm-billing-mode — read resolution +// PUT /admin/workspaces/:id/llm-billing-mode — write {mode: "..."|null} +// +// Both routes are on the per-tenant workspace-server (same origin as the +// other canvas /admin calls). CP's proxy at /cp/admin/workspaces/:id/ +// llm-billing-mode exists for ops use; the canvas uses the per-tenant +// path directly to keep the round-trip cheap. + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import { Section } from "./form-inputs"; + +// Mirrors workspace-server/internal/handlers/llm_billing_mode.go::BillingModeResolution. +// Kept as a literal shape (not imported) because canvas has no Go-type bridge. +export interface BillingModeResolution { + workspace_id: string; + resolved_mode: "platform_managed" | "byok" | "disabled"; + // Pointer-typed on the Go side: nil = inherit, non-nil = the raw + // workspace-level override (even if garbled and falling through). + workspace_override: string | null; + org_default: "platform_managed" | "byok" | "disabled"; + source: "workspace_override" | "org_default" | "constant_fallback"; +} + +// The dropdown emits one of these values. "inherit" is the UX-only label +// that maps to a `null` body in the PUT request. +type DropdownChoice = "inherit" | "platform_managed" | "byok" | "disabled"; + +interface Props { + workspaceId: string; +} + +const MODE_LABELS: Record = { + inherit: "Inherit from org default", + platform_managed: "Platform-managed (uses Molecule credits)", + byok: "BYOK (your own OAuth / vendor keys)", + disabled: "Disabled (no LLM access)", +}; + +const MODE_DESCRIPTIONS: Record = { + inherit: + "Use whichever mode is set at the organization level. Recommended unless this specific workspace needs a different billing source.", + platform_managed: + "Strip CLAUDE_CODE_OAUTH_TOKEN and vendor API keys from the workspace; route all LLM traffic through Molecule's proxy and bill your org credits.", + byok: + "Keep CLAUDE_CODE_OAUTH_TOKEN / vendor API keys in the workspace; LLM traffic goes directly to your provider and is billed to your OAuth subscription or API account.", + disabled: + "Block all LLM access for this workspace. Useful for sandbox workspaces that should not consume credits or hit external providers.", +}; + +const SOURCE_LABELS: Record = { + workspace_override: "explicit override on this workspace", + org_default: "inherited from org default", + constant_fallback: + "fallback (workspace + org defaults missing or unrecognized — defaulted to platform_managed)", +}; + +export function LLMBillingSection({ workspaceId }: Props) { + const [resolution, setResolution] = useState( + null, + ); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await api.get( + `/admin/workspaces/${workspaceId}/llm-billing-mode`, + ); + setResolution(res); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load billing mode"); + } finally { + setLoading(false); + } + }, [workspaceId]); + + useEffect(() => { + void load(); + }, [load]); + + // Current dropdown selection is derived from the resolution. If the + // override is null, we show "inherit"; otherwise we mirror the raw + // workspace_override (NOT resolved_mode — that would conflate "explicit + // platform_managed override" with "inherit while org happens to be + // platform_managed", which has different semantics on the write side). + const currentChoice: DropdownChoice = (() => { + if (!resolution) return "inherit"; + if (resolution.workspace_override == null) return "inherit"; + const raw = resolution.workspace_override; + if (raw === "platform_managed" || raw === "byok" || raw === "disabled") { + return raw; + } + // Garbled value persisted via some external write. Show inherit so + // the user can pick a clean value; on save they'll either clear it + // (PUT null) or overwrite it with a valid one. + return "inherit"; + })(); + + const handleChange = async (choice: DropdownChoice) => { + if (!resolution) return; + setSaving(true); + setError(null); + setSuccess(false); + try { + // "inherit" → PUT {mode: null}; otherwise → PUT {mode: choice}. + const body = choice === "inherit" ? { mode: null } : { mode: choice }; + const updated = await api.put( + `/admin/workspaces/${workspaceId}/llm-billing-mode`, + body, + ); + setResolution(updated); + setSuccess(true); + setTimeout(() => setSuccess(false), 2000); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update billing mode"); + } finally { + setSaving(false); + } + }; + + return ( +
+ {loading && ( +
Loading billing mode…
+ )} + + {error && ( +
+ {error} +
+ )} + + {resolution && ( +
+
+ Resolved mode: {resolution.resolved_mode}{" "} + + ({SOURCE_LABELS[resolution.source]}) + +
+
+ Org default: {resolution.org_default} +
+ + + + +
+ {MODE_DESCRIPTIONS[currentChoice]} +
+ + {success && ( +
+ Updated. Restart the workspace to apply. +
+ )} + + {resolution.workspace_override != null && + !["platform_managed", "byok", "disabled"].includes( + resolution.workspace_override, + ) && ( +
+ Workspace override has a non-standard value ( + {resolution.workspace_override}) and is being + ignored. Pick a valid mode above to clear the corrupt value. +
+ )} +
+ )} +
+ ); +} -- 2.52.0