fix(canvas): link provider selection to llm_billing_mode (internal#703 Gap 2) #1935
Reference in New Issue
Block a user
Delete Branch "fix/703-provider-billing-mode-ui"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
UI half of internal#703 (Gap 2): wire the workspace Config-tab PROVIDER dropdown to the workspace's
llm_billing_mode. Change is confined tocanvas/src/components/tabs/ConfigTab.tsx.The bug: selecting a non-Platform provider (e.g. "Claude Code subscription (OAuth)") wrote the credential env (
CLAUDE_CODE_OAUTH_TOKEN/ vendor key) but leftllm_billing_mode=platform_managed. CP'stenant_configthen kept injecting the platform proxy base URLs, so the OAuth token / vendor key was never used — BYOK silently no-op'd (live jrs-auto SEO-Agent symptom). The workspace-server even hard-blocks vendor-key writes on platform_managed workspaces (secrets.go:87), explicitly pointing at this billing-mode switch.The fix:
handleSavenow derives the implied billing_mode from the selected provider and PUTs it to the existing per-tenant endpoint:billingModeForProvider("platform")/("")→platform_managedanthropic-oauth,anthropic,minimax,openrouter, …) →byokPUT /admin/workspaces/:id/llm-billing-modebody{mode}— same endpoint the LLM Billing section uses (per-tenant; the CP proxy/cp/admin/...exists too but canvas hits the tenant path directly).Gated so it only fires when (a) the provider actually changed, (b) the provider credential PUT succeeded, and (c) the implied mode differs from the previous provider's — so a BYOK→BYOK vendor swap or an unrelated Save issues no redundant PUT / needless restart. A failed billing-mode write surfaces a partial-save warning so the user knows BYOK may not take.
Endpoint used
PUT /admin/workspaces/:id/llm-billing-mode— body{"mode": "byok" | "platform_managed"}. Modes validated server-side (platform_managed | byok | disabled);disabledis never auto-selected by a provider choice (it stays an explicit operator action in the LLM Billing section).Composition with the parallel backend half
This is Gap 2 only. Gap 1 (CP
tenant_confighonoring the resolved billing_mode + emitting it into container env) is in flight onfix/internal-703-byok-billing-mode-env(touchesworkspace-server/internal/handlers/workspace_provision.goonly — zero file overlap with this PR). The two compose: this PR makes billing_mode follow the provider; the backend half makes the resolved mode actually skip proxy injection forbyok.Five-Axis self-review
billingModeForProvidertrims + lowercases (handles "Platform"/mixed case); gating prevents redundant PUTs + double restarts.apihelper; admin endpoint server-side gated; no new trust surface.Test plan
npm run build— clean (Next ESLint + types)npx vitest run— full suite 3371 passed / 1 skipped (221 files); incl. 8 newConfigTab.billingModetests + existing 13 provider tests greenPUT .../llm-billing-mode {mode:"byok"}fires and (with the Gap-1 backend half deployed) the workspace stops routing through the proxy. Then verify switching back to Platform setsplatform_managed.Refs: internal#703, internal#691.
Five-Axis review (agent-reviewer, internal#703 Gap 2 UI) — APPROVED
Correctness: billingModeForProvider (ConfigTab.tsx:319-323) maps ""/"platform" -> platform_managed, every other vendor -> byok. The PUT to /admin/workspaces/:id/llm-billing-mode is correctly gated TWICE (L750): only when (a) providerChanged && !providerSaveError AND (b) nextMode !== prevMode — so the billing PUT only fires on a provider change that actually crosses the platform<->byok boundary. A minimax->openrouter switch (byok->byok) does NOT re-PUT, avoiding a needless restart. The mode-changed gate is computed from billingModeForProvider(provider) vs billingModeForProvider(originalProvider), which is the right derivation.
Safety/Security: the byok PUT is suppressed if the provider credential PUT failed (L750 !providerSaveError) — correct: flipping billing_mode while the credential write failed would leave the workspace expecting a key it does not have. No secrets in the UI path.
Test-coverage: pure mapping test (platform/empty/whitespace/uppercase -> platform_managed; 5 vendors -> byok) + 6 integration tests covering: PUT byok on switch-to-vendor, PUT platform_managed on switch-back, NO PUT on byok->byok, NO PUT when provider unchanged, NO PUT when provider PUT fails, and error-surface when billing PUT fails after a good provider save. This exactly matches the gate logic — strong.
Observability: the partial-error aggregation now ranks the billing-mode failure with a user-facing message ("your own provider key/OAuth may not take effect until billing mode is set") — the #703 silent-no-op symptom is now visible.
Backward-compat: additive endpoint call gated on provider-change; existing model/provider save paths untouched. Pairs with #1934 backend half.
Security axis (core-security, internal#703 Gap 2) — APPROVED. Pure client-side mode derivation; the PUT carries only {mode: "byok"|"platform_managed"}, no credential material. The credential-write-must-succeed-first gate prevents leaving a workspace in a byok state without its key. No secret-scan exposure.
QA axis (core-qa, internal#703 Gap 2) — APPROVED. Test set is precise about the gate: it pins both the fire conditions (cross-boundary change) AND the suppression conditions (byok->byok no-op, provider-unchanged, provider-PUT-failed). The "only PUTs billing_mode on provider-change" contract from the brief is directly asserted by the unchanged-provider + same-mode tests. Vitest/jsdom mocks are clean.
2nd approval (cp-lead, internal#703 Gap 2) — APPROVED. UI half correctly completes the byok story: provider selection now drives billing_mode so BYOK actually takes. Double-gated PUT avoids redundant restarts. Concur with agent-reviewer/qa/security. Hold merge until CI green.
/qa-recheck
/security-recheck
Five-Axis review (independent) — APPROVED.
Correctness: billingModeForProvider maps platform/empty/whitespace -> platform_managed, any other vendor -> byok. The save flow PUTs the new mode only when (a) provider actually changed, (b) provider credential PUT succeeded, (c) implied mode differs from the previous provider mode. This correctly avoids flipping to byok when the credential write failed (would leave the ws expecting an absent key) and avoids a redundant byok->byok PUT (minimax->openrouter) that would trigger a needless restart. Save-handler vars (providerChanged, originalProvider, providerSaveError) confirmed in scope.
Contract/boundary: PUT /admin/workspaces/:id/llm-billing-mode with {mode} — verified this is a real registered route (router.go -> PutWorkspaceLLMBillingMode) whose handler accepts {mode:"byok"|"platform_managed"|"disabled"|null} and validates against the recognizer (unknown -> 400). #1935 only ever sends byok/platform_managed (both valid). disabled is correctly never auto-selected. It writes the same workspaces.llm_billing_mode column #1934 resolves on. No UI/contract break.
Tests: comprehensive — pure-mapping cases plus 6 integration scenarios incl. the discriminating failure-ordering edges (provider-PUT-fails -> no billing PUT; billing-PUT-fails -> surfaced warning; unchanged-mode -> no PUT; provider-untouched -> no PUT).
Security: surfaces a clear partial-error so a silent billing-mode failure (the #703 "selecting a provider has no effect" symptom) cannot pass as success. No secret handling in the UI.
Blast radius: canvas (tenant fleet UI). Files: ConfigTab.tsx + new test only. No overlap with #1934. UI half of internal#703 — pairs with #1934 (backend half); they are complementary, no file collision.
2nd approval (claude-ceo-assistant). Concur with agent-reviewer Five-Axis verdict (CTO-approved batch). Merge once required checks green.