Wire native LLM auth selection into workspace creation #1833

Merged
agent-dev-b merged 1 commits from feat/llm-native-auth-flow into main 2026-05-25 05:05:03 +00:00
8 changed files with 417 additions and 78 deletions
+173 -3
View File
@@ -33,7 +33,51 @@ interface HermesProvider {
models: string[];
}
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
type LLMAuthMode = "platform" | "api_key" | "oauth";
interface NativeLLMProvider {
id: string;
label: string;
envVar?: string;
defaultModel: string;
models: string[];
authModes: LLMAuthMode[];
}
export const NATIVE_LLM_PROVIDERS: NativeLLMProvider[] = [
{
id: "minimax",
label: "MiniMax",
envVar: "MINIMAX_API_KEY",
defaultModel: "MiniMax-M2.7",
models: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"],
authModes: ["platform", "api_key"],
},
{
id: "kimi-coding",
label: "Kimi",
envVar: "KIMI_API_KEY",
defaultModel: "kimi-for-coding",
models: ["kimi-for-coding", "kimi-k2.5", "kimi-k2"],
authModes: ["platform", "api_key"],
},
{
id: "anthropic",
label: "Anthropic",
envVar: "ANTHROPIC_API_KEY",
defaultModel: "claude-sonnet-4-6",
models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"],
authModes: ["platform", "api_key"],
},
{
id: "anthropic-oauth",
label: "Claude OAuth",
envVar: "CLAUDE_CODE_OAUTH_TOKEN",
defaultModel: "sonnet",
models: ["sonnet", "opus", "haiku"],
authModes: ["oauth"],
},
];
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
@@ -105,6 +149,10 @@ export function CreateWorkspaceButton() {
// (Anthropic), which 401s if the user's key is for a different
// provider. Hence: require model when template=hermes.
const [hermesModel, setHermesModel] = useState("");
const [llmAuthMode, setLLMAuthMode] = useState<LLMAuthMode>("platform");
const [llmProvider, setLLMProvider] = useState("minimax");
const [llmModel, setLLMModel] = useState("MiniMax-M2.7");
const [llmSecret, setLLMSecret] = useState("");
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
@@ -161,6 +209,14 @@ export function CreateWorkspaceButton() {
);
const isHermes = template.trim().toLowerCase() === "hermes";
const nativeLLMProviders = useMemo(
() => NATIVE_LLM_PROVIDERS.filter((p) => p.authModes.includes(llmAuthMode)),
[llmAuthMode],
);
const selectedNativeProvider = useMemo(
() => nativeLLMProviders.find((p) => p.id === llmProvider) ?? nativeLLMProviders[0],
[llmProvider, nativeLLMProviders],
);
// Resolve the selected template's spec from the /templates response.
// The `template` input is free-text; templates can be matched by id,
@@ -208,6 +264,22 @@ export function CreateWorkspaceButton() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [availableProviders, isHermes]);
useEffect(() => {
if (isHermes) return;
if (nativeLLMProviders.length === 0) return;
if (!nativeLLMProviders.some((p) => p.id === llmProvider)) {
setLLMProvider(nativeLLMProviders[0].id);
setLLMModel(nativeLLMProviders[0].defaultModel);
}
}, [isHermes, llmProvider, nativeLLMProviders]);
useEffect(() => {
if (isHermes || !selectedNativeProvider) return;
if (!selectedNativeProvider.models.includes(llmModel)) {
setLLMModel(selectedNativeProvider.defaultModel);
}
}, [isHermes, llmModel, selectedNativeProvider]);
// Auto-fill hermesModel with the provider's defaultModel whenever the
// provider changes, but only if the user hasn't already typed their own
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
@@ -242,6 +314,10 @@ export function CreateWorkspaceButton() {
setExternalRuntime("external");
setHermesApiKey("");
setHermesModel("");
setLLMAuthMode("platform");
setLLMProvider("minimax");
setLLMModel("MiniMax-M2.7");
setLLMSecret("");
api
.get<WorkspaceOption[]>("/workspaces")
.then((ws) => setWorkspaces(ws))
@@ -268,12 +344,21 @@ export function CreateWorkspaceButton() {
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
return;
}
if (!isExternal && !isHermes && !llmModel.trim()) {
setError("Model is required");
return;
}
if (!isExternal && !isHermes && llmAuthMode !== "platform" && !llmSecret.trim()) {
setError(llmAuthMode === "oauth" ? "Claude OAuth token is required" : "API key is required");
return;
}
setCreating(true);
setError(null);
const provider = isHermes
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
: undefined;
const nativeProvider = !isHermes ? selectedNativeProvider : undefined;
try {
const parsedBudget = budgetLimit.trim()
@@ -297,7 +382,15 @@ export function CreateWorkspaceButton() {
tier,
parent_id: parentId || undefined,
budget_limit: parsedBudget,
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
...(!isExternal && !isHermes && nativeProvider
? {
model: llmModel.trim(),
llm_provider: nativeProvider.id,
...(llmAuthMode !== "platform" && nativeProvider.envVar
? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } }
: {}),
}
: {}),
...(!isExternal
? {
compute: displayEnabled
@@ -449,6 +542,82 @@ export function CreateWorkspaceButton() {
/>
)}
{!isExternal && !isHermes && selectedNativeProvider && (
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3 space-y-3">
<div className="text-[11px] font-medium text-ink-mid">
LLM
</div>
<div>
<label htmlFor="llm-auth-mode" className="text-[11px] text-ink-mid block mb-1">
Auth Mode
</label>
<select
id="llm-auth-mode"
value={llmAuthMode}
onChange={(e) => setLLMAuthMode(e.target.value as LLMAuthMode)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="platform">Platform provided</option>
<option value="api_key">API key</option>
<option value="oauth">Claude OAuth</option>
</select>
</div>
<div>
<label htmlFor="llm-provider-select" className="text-[11px] text-ink-mid block mb-1">
Provider
</label>
<select
id="llm-provider-select"
value={selectedNativeProvider.id}
onChange={(e) => {
const next = nativeLLMProviders.find((p) => p.id === e.target.value);
setLLMProvider(e.target.value);
if (next) setLLMModel(next.defaultModel);
}}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
{nativeLLMProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="llm-model-input" className="text-[11px] text-ink-mid block mb-1">
Model
</label>
<input
id="llm-model-input"
type="text"
value={llmModel}
onChange={(e) => setLLMModel(e.target.value)}
list="llm-model-suggestions"
spellCheck={false}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
/>
<datalist id="llm-model-suggestions">
{selectedNativeProvider.models.map((m) => <option key={m} value={m} />)}
</datalist>
</div>
{llmAuthMode !== "platform" && (
<div>
<label htmlFor="llm-secret-input" className="text-[11px] text-ink-mid block mb-1">
{llmAuthMode === "oauth" ? "OAuth Token" : "API Key"}
</label>
<input
id="llm-secret-input"
type="password"
value={llmSecret}
onChange={(e) => setLLMSecret(e.target.value)}
autoComplete="off"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
/>
</div>
)}
</div>
)}
<div>
<div
role="radiogroup"
@@ -553,10 +722,11 @@ export function CreateWorkspaceButton() {
)}
<div>
<label className="text-[11px] text-ink-mid block mb-1">
<label htmlFor="parent-workspace-select" className="text-[11px] text-ink-mid block mb-1">
Parent Workspace
</label>
<select
id="parent-workspace-select"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
@@ -63,7 +63,7 @@ describe("CreateWorkspaceDialog", () => {
it('first option is "None (root level)" with empty value', async () => {
await openDialog();
const select = document.querySelector("select") as HTMLSelectElement;
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
expect(select).toBeTruthy();
const firstOption = select.options[0];
expect(firstOption.value).toBe("");
@@ -73,12 +73,12 @@ describe("CreateWorkspaceDialog", () => {
it("populates select with workspace names from GET /workspaces", async () => {
await openDialog();
await waitFor(() => {
const select = document.querySelector("select") as HTMLSelectElement;
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const optionValues = Array.from(select.options).map((o) => o.value);
expect(optionValues).toContain("ws-1");
expect(optionValues).toContain("ws-2");
});
const select = document.querySelector("select") as HTMLSelectElement;
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((o) => o.text.trim());
expect(optionTexts.some((t) => t.includes("Platform Team"))).toBe(true);
expect(optionTexts.some((t) => t.includes("Research Agent"))).toBe(true);
@@ -87,7 +87,7 @@ describe("CreateWorkspaceDialog", () => {
it("sends parent_id in POST body when a workspace is selected", async () => {
await openDialog();
await waitFor(() => {
const select = document.querySelector("select") as HTMLSelectElement;
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
@@ -95,7 +95,7 @@ describe("CreateWorkspaceDialog", () => {
target: { value: "My Agent" },
});
const select = document.querySelector("select") as HTMLSelectElement;
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "ws-1" } });
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
@@ -112,7 +112,7 @@ describe("CreateWorkspaceDialog", () => {
target: { value: "Root Agent" },
});
const select = document.querySelector("select") as HTMLSelectElement;
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "" } });
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
@@ -139,7 +139,9 @@ describe("CreateWorkspaceDialog", () => {
volume: { root_gb: 30 },
display: { mode: "none" },
});
expect(body.model).toBe("anthropic:claude-opus-4-7");
expect(body.model).toBe("MiniMax-M2.7");
expect(body.llm_provider).toBe("minimax");
expect(body.secrets).toBeUndefined();
});
it("does not send managed compute for external agents", async () => {
@@ -170,7 +172,8 @@ describe("CreateWorkspaceDialog", () => {
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("anthropic:claude-opus-4-7");
expect(body.model).toBe("MiniMax-M2.7");
expect(body.llm_provider).toBe("minimax");
expect(body.compute).toEqual({
instance_type: "t3.xlarge",
volume: { root_gb: 80 },
@@ -183,13 +186,57 @@ describe("CreateWorkspaceDialog", () => {
});
});
it("sends BYOK API key secrets when API key auth mode is selected", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "BYOK Agent" },
});
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
target: { value: "api_key" },
});
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
target: { value: "sk-minimax-test" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("MiniMax-M2.7");
expect(body.llm_provider).toBe("minimax");
expect(body.secrets).toEqual({ MINIMAX_API_KEY: "sk-minimax-test" });
});
it("sends Claude OAuth token separately from platform-managed mode", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "OAuth Agent" },
});
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
target: { value: "oauth" },
});
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
target: { value: "oauth-token" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("sonnet");
expect(body.llm_provider).toBe("anthropic-oauth");
expect(body.secrets).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "oauth-token" });
});
it("renders gracefully when GET /workspaces fails", async () => {
mockGet.mockRejectedValueOnce(new Error("Network error"));
await openDialog();
// Dialog still renders; select exists with only the root option
await waitFor(() => {
const select = document.querySelector("select") as HTMLSelectElement;
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
expect(select.options.length).toBe(1);
expect(select.options[0].value).toBe("");
});
@@ -143,46 +143,30 @@ afterEach(() => {
// ── Tests ────────────────────────────────────────────────────────────────────
/**
* Drive the always-show-picker flow to completion: deploy() opens the
* modal, then we click "keys added" to fire the actual POST. Centralised
* here because as of the always-prompt change, every happy-path test
* must click through the modal before asserting on POST.
*/
async function deployThroughPicker<T>(
result: { current: ReturnType<typeof useTemplateDeploy> },
rerender: () => void,
template: Template,
): Promise<void> {
await act(async () => {
await result.current.deploy(template);
});
rerender();
render(<>{result.current.modal}</>);
await act(async () => {
fireEvent.click(screen.getByTestId("modal-keys-added"));
// Let the fire-and-forget executeDeploy resolve.
await Promise.resolve();
await Promise.resolve();
});
}
describe("useTemplateDeploy — happy path", () => {
it("preflight ok → modal opens → keys-added → POST /workspaces → onDeployed fires", async () => {
it("preflight ok with no key requirements → POST /workspaces directly → onDeployed fires", async () => {
const onDeployed = vi.fn();
const { result, rerender } = renderHook(() =>
const { result } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await deployThroughPicker(result, rerender, makeTemplate());
await act(async () => {
await result.current.deploy(makeTemplate({
id: "seo-agent",
name: "SEO Agent",
model: "MiniMax-M2.7",
}));
});
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({
name: "Claude Code",
template: "claude-code-default",
name: "SEO Agent",
template: "seo-agent",
tier: 1,
model: "MiniMax-M2.7",
llm_provider: "minimax",
}),
);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
@@ -192,11 +176,13 @@ describe("useTemplateDeploy — happy path", () => {
it("uses caller-supplied canvasCoords when provided", async () => {
const canvasCoords = vi.fn(() => ({ x: 42, y: 99 }));
const { result, rerender } = renderHook(() =>
const { result } = renderHook(() =>
useTemplateDeploy({ canvasCoords }),
);
await deployThroughPicker(result, rerender, makeTemplate());
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(canvasCoords).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
@@ -206,9 +192,11 @@ describe("useTemplateDeploy — happy path", () => {
});
it("falls back to random coords inside [100,500] × [100,400] when canvasCoords omitted", async () => {
const { result, rerender } = renderHook(() => useTemplateDeploy());
const { result } = renderHook(() => useTemplateDeploy());
await deployThroughPicker(result, rerender, makeTemplate());
await act(async () => {
await result.current.deploy(makeTemplate());
});
const body = (mockApiPost as Mock).mock.calls[0]?.[1] as {
canvas: { x: number; y: number };
@@ -458,16 +446,9 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
);
});
it("single-provider template ALSO opens picker when preflight.ok (always-prompt rule)", async () => {
// Default preflight mock: ok=true, providers=[]. claude-code is
// single-provider, but the always-prompt rule means the user must
// still click through the picker to confirm provider+model — even
// when keys are saved and the runtime has only one provider option.
// Reason: the user needs an explicit chance to override the
// template's default model (e.g. opus vs sonnet vs haiku) before
// an EC2 boots and burns billing on the wrong tier.
it("template with no provider requirements deploys directly on platform-managed defaults", async () => {
const onDeployed = vi.fn();
const { result, rerender } = renderHook(() =>
const { result } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
@@ -475,13 +456,18 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
await result.current.deploy(makeTemplate());
});
rerender();
render(<>{result.current.modal}</>);
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
// POST does NOT fire until the user confirms in the picker.
expect(mockApiPost).not.toHaveBeenCalled();
expect(onDeployed).not.toHaveBeenCalled();
expect(screen.queryByTestId("missing-keys-modal")).toBeNull();
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({
template: "claude-code-default",
model: "claude-sonnet-4-5",
llm_provider: "anthropic",
}),
);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
expect(result.current.deploying).toBeNull();
});
@@ -519,11 +505,13 @@ describe("useTemplateDeploy — POST failure", () => {
it("POST rejection sets error and clears deploying", async () => {
mockApiPost.mockRejectedValueOnce(new Error("server 500"));
const onDeployed = vi.fn();
const { result, rerender } = renderHook(() =>
const { result } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await deployThroughPicker(result, rerender, makeTemplate());
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(result.current.error).toBe("server 500");
expect(result.current.deploying).toBeNull();
@@ -532,9 +520,11 @@ describe("useTemplateDeploy — POST failure", () => {
it("non-Error rejection still surfaces a message (defensive)", async () => {
mockApiPost.mockRejectedValueOnce("plain string");
const { result, rerender } = renderHook(() => useTemplateDeploy());
const { result } = renderHook(() => useTemplateDeploy());
await deployThroughPicker(result, rerender, makeTemplate());
await act(async () => {
await result.current.deploy(makeTemplate());
});
expect(result.current.error).toBe("Deploy failed");
expect(result.current.deploying).toBeNull();
+30 -4
View File
@@ -55,6 +55,22 @@ interface MissingKeysInfo {
preflight: PreflightResult;
}
function nativeProviderForClaudeCodeModel(model: string): string | undefined {
const trimmed = model.trim();
const lower = trimmed.toLowerCase();
if (!trimmed) return undefined;
if (lower.startsWith("minimax")) return "minimax";
if (lower.startsWith("kimi")) return "kimi-coding";
if (lower.startsWith("claude")) return "anthropic";
if (/^(sonnet|opus|haiku)$/.test(lower)) return "anthropic-oauth";
return undefined;
}
function isNativeClaudeCodeRuntime(template: Template): boolean {
const runtime = template.runtime ?? resolveRuntime(template.id);
return runtime === "claude-code";
}
export interface UseTemplateDeployResult {
/** Template id currently being deployed (incl. the preflight
* network call), or null when idle. Callers pass this to disable
@@ -97,6 +113,10 @@ export function useTemplateDeploy(
setDeploying(template.id);
setError(null);
try {
const selectedModel = model?.trim() || template.model?.trim();
const nativeProvider = isNativeClaudeCodeRuntime(template) && selectedModel
? nativeProviderForClaudeCodeModel(selectedModel)
: undefined;
const coords = canvasCoords
? canvasCoords()
: {
@@ -108,7 +128,8 @@ export function useTemplateDeploy(
template: template.id,
tier: isSaaSTenant() ? 4 : template.tier,
canvas: coords,
...(model ? { model } : {}),
...(selectedModel ? { model: selectedModel } : {}),
...(nativeProvider ? { llm_provider: nativeProvider } : {}),
});
onDeployed?.(ws.id);
} catch (e) {
@@ -144,8 +165,13 @@ export function useTemplateDeploy(
setDeploying(null);
return;
}
// Always open the picker — every deploy goes through an
// explicit confirm-provider/model step. Reasons:
if (preflight.ok && preflight.providers.length === 0) {
await executeDeploy(template);
return;
}
// Open the picker whenever a template declares provider/key choices.
// Templates with no provider requirements deploy directly on the
// platform-managed default above. Reasons to keep the picker here:
// 1. Multi-provider templates (e.g. hermes) need a per-
// workspace pick or the adapter falls back to its
// compiled-in default and 500s with "No LLM provider
@@ -164,7 +190,7 @@ export function useTemplateDeploy(
setMissingKeysInfo({ template, preflight });
setDeploying(null);
},
[],
[executeDeploy],
);
// No useCallback here — consumers call this on every render anyway
@@ -612,7 +612,11 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
if err := setModelSecret(ctx, id, payload.Model); err != nil {
log.Printf("Create workspace %s: failed to persist MODEL_PROVIDER %q: %v (non-fatal)", id, payload.Model, err)
}
if derived := deriveProviderFromModelSlug(payload.Model); derived != "" {
if explicitProvider := strings.TrimSpace(payload.LLMProvider); explicitProvider != "" {
if err := setProviderSecret(ctx, id, explicitProvider); err != nil {
log.Printf("Create workspace %s: failed to persist LLM_PROVIDER %q: %v (non-fatal)", id, explicitProvider, err)
}
} else if derived := deriveProviderFromModelSlug(payload.Model); derived != "" {
if err := setProviderSecret(ctx, id, derived); err != nil {
log.Printf("Create workspace %s: failed to persist LLM_PROVIDER %q: %v (non-fatal)", id, derived, err)
}
@@ -912,13 +912,14 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// applyPlatformManagedLLMEnv wires the control-plane LLM proxy into a
// workspace only when the org is in platform-managed mode. Provider keys
// never enter the tenant; OPENAI_API_KEY is the tenant token for the CP
// OpenAI-compatible proxy.
func applyPlatformManagedLLMEnv(envVars map[string]string, _ string, model string) {
// never enter the tenant; provider SDK API-key envs receive the tenant token
// for the CP proxy only when the workspace has not supplied BYOK/OAuth auth.
func applyPlatformManagedLLMEnv(envVars map[string]string, runtime string, model string) {
if strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE"))) != "platform_managed" {
return
}
baseURL := firstNonEmptyEnv("MOLECULE_LLM_BASE_URL", "OPENAI_BASE_URL")
anthropicBaseURL := firstNonEmptyEnv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "ANTHROPIC_BASE_URL")
token := firstNonEmptyEnv("MOLECULE_LLM_USAGE_TOKEN", "OPENAI_API_KEY")
if baseURL == "" || token == "" {
return
@@ -927,14 +928,21 @@ func applyPlatformManagedLLMEnv(envVars map[string]string, _ string, model strin
envVars["MOLECULE_LLM_BILLING_MODE"] = "platform_managed"
envVars["MOLECULE_LLM_BASE_URL"] = baseURL
envVars["MOLECULE_LLM_USAGE_TOKEN"] = token
if anthropicBaseURL != "" {
envVars["MOLECULE_LLM_ANTHROPIC_BASE_URL"] = anthropicBaseURL
}
if usageURL := strings.TrimSpace(os.Getenv("MOLECULE_LLM_USAGE_URL")); usageURL != "" {
envVars["MOLECULE_LLM_USAGE_URL"] = usageURL
}
if strings.TrimSpace(envVars["OPENAI_API_KEY"]) == "" {
if strings.TrimSpace(envVars["OPENAI_API_KEY"]) == "" && !runtimeUsesAnthropicNativeProxy(runtime) {
envVars["OPENAI_API_KEY"] = token
envVars["OPENAI_BASE_URL"] = baseURL
}
if runtimeUsesAnthropicNativeProxy(runtime) && anthropicBaseURL != "" && workspaceHasNoAnthropicAuth(envVars) {
envVars["ANTHROPIC_API_KEY"] = token
envVars["ANTHROPIC_BASE_URL"] = anthropicBaseURL
}
if model == "" && strings.TrimSpace(envVars["MOLECULE_MODEL"]) == "" && strings.TrimSpace(envVars["MODEL"]) == "" {
if defaultModel := strings.TrimSpace(os.Getenv("MOLECULE_LLM_DEFAULT_MODEL")); defaultModel != "" {
@@ -943,6 +951,27 @@ func applyPlatformManagedLLMEnv(envVars map[string]string, _ string, model strin
}
}
func runtimeUsesAnthropicNativeProxy(runtime string) bool {
return strings.TrimSpace(strings.ToLower(runtime)) == "claude-code"
}
func workspaceHasNoAnthropicAuth(envVars map[string]string) bool {
for _, key := range []string{
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"MINIMAX_API_KEY",
"KIMI_API_KEY",
"GLM_API_KEY",
"DEEPSEEK_API_KEY",
} {
if strings.TrimSpace(envVars[key]) != "" {
return false
}
}
return true
}
func firstNonEmptyEnv(names ...string) string {
for _, name := range names {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
@@ -964,7 +964,7 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
}
}
func TestApplyPlatformManagedLLMEnv_DefaultsOpenAIProxyWhenNoWorkspaceKey(t *testing.T) {
func TestApplyPlatformManagedLLMEnv_NonClaudeRuntimeDefaultsOpenAIProxyWhenNoWorkspaceKey(t *testing.T) {
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
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")
@@ -972,8 +972,8 @@ func TestApplyPlatformManagedLLMEnv_DefaultsOpenAIProxyWhenNoWorkspaceKey(t *tes
t.Setenv("MOLECULE_LLM_DEFAULT_MODEL", "moonshot/kimi-k2.6")
envVars := map[string]string{}
applyPlatformManagedLLMEnv(envVars, "claude-code", "")
applyRuntimeModelEnv(envVars, "claude-code", "")
applyPlatformManagedLLMEnv(envVars, "codex", "")
applyRuntimeModelEnv(envVars, "codex", "")
if got := envVars["OPENAI_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/openai/v1" {
t.Fatalf("OPENAI_BASE_URL = %q", got)
@@ -1018,6 +1018,75 @@ func TestApplyPlatformManagedLLMEnv_DoesNotOverrideWorkspaceOpenAIKey(t *testing
}
}
func TestApplyPlatformManagedLLMEnv_ClaudeCodeUsesAnthropicProxyWithoutOverwritingOAuth(t *testing.T) {
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
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/v1")
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
envVars := map[string]string{
"CLAUDE_CODE_OAUTH_TOKEN": "user-oauth-token",
"MODEL": "sonnet",
}
applyPlatformManagedLLMEnv(envVars, "claude-code", "")
if got := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; got != "user-oauth-token" {
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN was overwritten: %q", got)
}
if _, ok := envVars["ANTHROPIC_API_KEY"]; ok {
t.Fatalf("ANTHROPIC_API_KEY should not be set when Claude OAuth is present")
}
if got := envVars["MOLECULE_LLM_ANTHROPIC_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/anthropic/v1" {
t.Fatalf("MOLECULE_LLM_ANTHROPIC_BASE_URL = %q", got)
}
}
func TestApplyPlatformManagedLLMEnv_ClaudeCodeInjectsAnthropicProxyWhenNoWorkspaceKey(t *testing.T) {
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
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/v1")
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
envVars := map[string]string{}
applyPlatformManagedLLMEnv(envVars, "claude-code", "minimax/MiniMax-M2.7")
if got := envVars["ANTHROPIC_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/anthropic/v1" {
t.Fatalf("ANTHROPIC_BASE_URL = %q", got)
}
if got := envVars["ANTHROPIC_API_KEY"]; got != "tenant-admin-token" {
t.Fatalf("ANTHROPIC_API_KEY = %q", got)
}
if got := envVars["MOLECULE_LLM_USAGE_TOKEN"]; got != "tenant-admin-token" {
t.Fatalf("MOLECULE_LLM_USAGE_TOKEN = %q", got)
}
}
func TestApplyPlatformManagedLLMEnv_ClaudeCodeDoesNotOverrideVendorBYOK(t *testing.T) {
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
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/v1")
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
envVars := map[string]string{
"MINIMAX_API_KEY": "user-minimax-key",
"MODEL": "MiniMax-M2.7",
}
applyPlatformManagedLLMEnv(envVars, "claude-code", "")
if got := envVars["MINIMAX_API_KEY"]; got != "user-minimax-key" {
t.Fatalf("MINIMAX_API_KEY was overwritten: %q", got)
}
if _, ok := envVars["ANTHROPIC_API_KEY"]; ok {
t.Fatalf("ANTHROPIC_API_KEY should not be set when vendor BYOK is present")
}
if _, ok := envVars["ANTHROPIC_BASE_URL"]; ok {
t.Fatalf("ANTHROPIC_BASE_URL should not be set when vendor BYOK is present")
}
if got := envVars["MOLECULE_LLM_USAGE_TOKEN"]; got != "tenant-admin-token" {
t.Fatalf("MOLECULE_LLM_USAGE_TOKEN = %q", got)
}
}
func TestApplyPlatformManagedLLMEnv_NoopsOutsidePlatformManaged(t *testing.T) {
t.Setenv("MOLECULE_LLM_BILLING_MODE", "byok")
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
@@ -176,9 +176,13 @@ type CreateWorkspacePayload struct {
Template string `json:"template"` // workspace-configs-templates folder name
Tier int `json:"tier"`
Model string `json:"model"`
Runtime string `json:"runtime"` // "claude-code" (default), "codex", etc.
External bool `json:"external"` // true = no Docker container, just a registered URL
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
// LLMProvider is the optional provider slug paired with Model. Runtimes
// such as claude-code need a bare model id plus explicit provider slug;
// hermes can still derive provider from slash-prefixed model ids.
LLMProvider string `json:"llm_provider"`
Runtime string `json:"runtime"` // "claude-code" (default), "codex", etc.
External bool `json:"external"` // true = no Docker container, just a registered URL
URL string `json:"url"` // for external workspaces: the A2A endpoint URL (push mode only — omit for poll)
// DeliveryMode: "push" (default) sends inbound A2A to URL synchronously;
// "poll" records inbound to activity_logs for the agent to consume via
// GET /activity?since_id=. Poll mode does not require a URL. See #2339.