源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# LiteLLM per-member keys
|
||||
|
||||
This zero-dependency Node.js example reconciles OpenWork Cloud per-member LLM credential bindings with LiteLLM virtual keys. It is an example provisioner, not a native LiteLLM integration in Den.
|
||||
|
||||
## Configure
|
||||
|
||||
Set these environment variables:
|
||||
|
||||
- `OPENWORK_DEN_API_URL`: Den API base URL
|
||||
- `OPENWORK_DEN_TOKEN`: organization owner or admin bearer token
|
||||
- `OPENWORK_ORG_ID`: organization ID sent as `x-openwork-org-id`
|
||||
- `OPENWORK_LLM_PROVIDER_ID`: the per-member LLM provider ID
|
||||
- `LITELLM_BASE_URL`: LiteLLM base URL, with or without `/v1`
|
||||
- `LITELLM_MASTER_KEY`: LiteLLM master key
|
||||
- `LITELLM_MODELS`: comma-separated model IDs assigned to each virtual key
|
||||
|
||||
Run reconciliation after granting provider access:
|
||||
|
||||
```bash
|
||||
node provision.mjs reconcile
|
||||
```
|
||||
|
||||
Before provisioning keys, the script reads the existing custom, `per_member` provider from Den and reconciles its configured model metadata from LiteLLM `GET /model_group/info` using master-key authentication. It preserves the provider config, model names and unknown model fields, and current member/team access while updating token limits. When LiteLLM supplies the corresponding facts, it also maps function calling, reasoning, vision, response schemas, and temperature support to Den's `tool_call`, `reasoning`, `attachment`, `structured_output`, and `temperature` model fields. It only PATCHes Den when those values changed.
|
||||
|
||||
Every model in `LITELLM_MODELS` must have an exact `model_group` match with finite, positive `max_input_tokens` and `max_output_tokens`. Reconciliation fails closed before creating member keys if LiteLLM omits a requested model or either limit. The example never guesses token limits or falls back to a generic value.
|
||||
|
||||
After metadata is synchronized, the script lists Den member credential states, mints a LiteLLM virtual key for each `missing` member, and writes the key to Den with LiteLLM's `token_id` as `externalCredentialId`. Summaries report the metadata action and safe model limits but never contain member keys.
|
||||
|
||||
## Run the complete local world
|
||||
|
||||
From the repository root, launch a signed-in Desktop, an isolated Den organization, and a database-backed LiteLLM gateway with the provider and member keys already reconciled:
|
||||
|
||||
```bash
|
||||
pnpm world up ./worlds/litellm-per-member.ts
|
||||
```
|
||||
|
||||
The command prints the Den URLs, LiteLLM URL, synchronized model limits, Den provider record ID, and Desktop CDP URL. Keep it running while testing and press Ctrl-C to tear down the world. Docker, local MySQL, and local Redis are required. The gateway uses a deterministic local OpenAI-compatible witness and does not read or require `OPENAI_API_KEY`.
|
||||
|
||||
Offboard a member by organization membership ID:
|
||||
|
||||
```bash
|
||||
node provision.mjs offboard member_...
|
||||
```
|
||||
|
||||
**Ordering rule:** block the upstream LiteLLM key first, then mark the Den binding blocked. LiteLLM v1.97 accepts its generated `token_id` in `POST /key/block`, so offboarding does not need the plaintext member key.
|
||||
|
||||
See [Per-member LLM credentials](../../packages/docs/cloud/share-with-your-team/per-member-llm-credentials.mdx) for the API contract. The executable proof is [`litellm-per-member-credentials.e2e.test.ts`](../../evals/specs/litellm-per-member-credentials.e2e.test.ts).
|
||||
@@ -0,0 +1,554 @@
|
||||
// @ts-check
|
||||
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* @typedef {object} ProvisionerConfig
|
||||
* @property {string} denApiUrl
|
||||
* @property {string} denToken
|
||||
* @property {string} orgId
|
||||
* @property {string} providerId
|
||||
* @property {string} liteLlmBaseUrl
|
||||
* @property {string} liteLlmMasterKey
|
||||
* @property {string[]} models
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} MemberCredentialSummary
|
||||
* @property {string} orgMembershipId
|
||||
* @property {"provisioned"} action
|
||||
* @property {string} externalCredentialId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ModelMetadataSummary
|
||||
* @property {string} id
|
||||
* @property {number} maxInputTokens
|
||||
* @property {number} maxOutputTokens
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ModelMetadataResult
|
||||
* @property {"unchanged" | "updated"} action
|
||||
* @property {ModelMetadataSummary[]} models
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ReconcileResult
|
||||
* @property {ModelMetadataResult} modelMetadata
|
||||
* @property {MemberCredentialSummary[]} memberCredentials
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CurrentProviderModel
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {Record<string, unknown>} config
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} LiteLlmModelMetadata
|
||||
* @property {string} id
|
||||
* @property {number} maxInputTokens
|
||||
* @property {number} maxOutputTokens
|
||||
* @property {Record<string, unknown>} facts
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is Record<string, unknown>}
|
||||
*/
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
* @returns {string}
|
||||
*/
|
||||
function requireString(value, label) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${label} must be a non-empty string.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
*/
|
||||
function requirePositiveNumber(value, label) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a finite positive number.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function cleanBaseUrl(value) {
|
||||
return requireString(value, "Base URL").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function liteLlmAdminBaseUrl(value) {
|
||||
return cleanBaseUrl(value).replace(/\/v1$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string[]} secrets
|
||||
*/
|
||||
function redact(text, secrets) {
|
||||
return secrets
|
||||
.filter((secret) => secret.length > 0)
|
||||
.reduce((result, secret) => result.split(secret).join("[REDACTED]"), text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} body
|
||||
* @param {string} text
|
||||
*/
|
||||
function responseErrorText(body, text) {
|
||||
if (isRecord(body)) {
|
||||
if (typeof body.message === "string") return body.message;
|
||||
if (typeof body.error === "string") return body.error;
|
||||
if (isRecord(body.error) && typeof body.error.message === "string") return body.error.message;
|
||||
}
|
||||
return text || "empty response";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {RequestInit} init
|
||||
* @param {string[]} secrets
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
async function requestJson(url, init, secrets) {
|
||||
const response = await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(30_000) });
|
||||
const text = await response.text();
|
||||
/** @type {unknown} */
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const method = init.method ?? "GET";
|
||||
const pathname = new URL(url).pathname;
|
||||
throw new Error(redact(`${method} ${pathname} failed with HTTP ${response.status}: ${responseErrorText(body, text).slice(0, 1_000)}`, secrets));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** @param {ProvisionerConfig} input */
|
||||
function denHeaders(input) {
|
||||
return {
|
||||
authorization: `Bearer ${input.denToken}`,
|
||||
"content-type": "application/json",
|
||||
"x-openwork-org-id": input.orgId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ProvisionerConfig} input
|
||||
*/
|
||||
function configuredModels(input) {
|
||||
const models = [...new Set(input.models.map((model) => requireString(model, "model")))];
|
||||
if (models.length === 0) throw new Error("models must contain at least one model id.");
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} providerId
|
||||
*/
|
||||
function manageableProvider(value, providerId) {
|
||||
if (!isRecord(value) || !Array.isArray(value.llmProviders)) {
|
||||
throw new Error("Den manageable provider response had an invalid shape.");
|
||||
}
|
||||
const provider = value.llmProviders.filter(isRecord).find((entry) => entry.id === providerId);
|
||||
if (!provider) throw new Error(`Den provider ${providerId} was not found in the manageable provider list.`);
|
||||
if (provider.source !== "custom") throw new Error(`Den provider ${providerId} must have source custom.`);
|
||||
if (provider.credentialMode !== "per_member") throw new Error(`Den provider ${providerId} must have credentialMode per_member.`);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} provider
|
||||
* @returns {CurrentProviderModel[]}
|
||||
*/
|
||||
function currentProviderModels(provider) {
|
||||
if (!Array.isArray(provider.models)) throw new Error("Den manageable provider did not include models.");
|
||||
return provider.models.map((value) => {
|
||||
if (!isRecord(value) || !isRecord(value.config)) {
|
||||
throw new Error("Den manageable provider included an invalid model config.");
|
||||
}
|
||||
return {
|
||||
id: requireString(value.id, "Den model id"),
|
||||
name: requireString(value.name, `Den model ${String(value.id)} name`),
|
||||
config: value.config,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} provider
|
||||
*/
|
||||
function currentProviderAccess(provider) {
|
||||
if (!isRecord(provider.access)
|
||||
|| typeof provider.access.allMembers !== "boolean"
|
||||
|| !Array.isArray(provider.access.members)
|
||||
|| !Array.isArray(provider.access.teams)) {
|
||||
throw new Error("Den manageable provider did not include a valid access summary.");
|
||||
}
|
||||
const memberIds = provider.access.members.map((entry) => {
|
||||
if (!isRecord(entry)) throw new Error("Den manageable provider included an invalid member access grant.");
|
||||
return requireString(entry.orgMembershipId, "Den member access orgMembershipId");
|
||||
});
|
||||
const teamIds = provider.access.teams.map((entry) => {
|
||||
if (!isRecord(entry)) throw new Error("Den manageable provider included an invalid team access grant.");
|
||||
return requireString(entry.teamId, "Den team access teamId");
|
||||
});
|
||||
return {
|
||||
allMembers: provider.access.allMembers,
|
||||
memberIds: [...new Set(memberIds)].sort(),
|
||||
teamIds: [...new Set(teamIds)].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string[]} models
|
||||
* @returns {LiteLlmModelMetadata[]}
|
||||
*/
|
||||
function liteLlmModelMetadata(value, models) {
|
||||
if (!isRecord(value) || !Array.isArray(value.data)) {
|
||||
throw new Error("LiteLLM /model_group/info response had an invalid shape.");
|
||||
}
|
||||
const entries = value.data.filter(isRecord);
|
||||
return models.map((id) => {
|
||||
const matches = entries.filter((entry) => entry.model_group === id);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`LiteLLM /model_group/info did not include requested model_group ${id}.`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`LiteLLM /model_group/info included duplicate metadata for model_group ${id}.`);
|
||||
}
|
||||
const facts = matches[0];
|
||||
if (!facts) throw new Error(`LiteLLM /model_group/info did not include requested model_group ${id}.`);
|
||||
return {
|
||||
id,
|
||||
maxInputTokens: requirePositiveNumber(
|
||||
facts.max_input_tokens,
|
||||
`LiteLLM model_group ${id} max_input_tokens`,
|
||||
),
|
||||
maxOutputTokens: requirePositiveNumber(
|
||||
facts.max_output_tokens,
|
||||
`LiteLLM model_group ${id} max_output_tokens`,
|
||||
),
|
||||
facts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CurrentProviderModel} model
|
||||
* @param {LiteLlmModelMetadata | undefined} metadata
|
||||
*/
|
||||
function synchronizedModelConfig(model, metadata) {
|
||||
if (!metadata) return { ...model.config, id: model.id, name: model.name };
|
||||
const currentLimit = isRecord(model.config.limit) ? model.config.limit : {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const config = {
|
||||
...model.config,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
limit: {
|
||||
...currentLimit,
|
||||
context: metadata.maxInputTokens,
|
||||
input: metadata.maxInputTokens,
|
||||
output: metadata.maxOutputTokens,
|
||||
},
|
||||
};
|
||||
if (typeof metadata.facts.supports_function_calling === "boolean") {
|
||||
config.tool_call = metadata.facts.supports_function_calling;
|
||||
}
|
||||
if (typeof metadata.facts.supports_reasoning === "boolean") {
|
||||
config.reasoning = metadata.facts.supports_reasoning;
|
||||
}
|
||||
if (typeof metadata.facts.supports_vision === "boolean") {
|
||||
config.attachment = metadata.facts.supports_vision;
|
||||
}
|
||||
if (typeof metadata.facts.supports_response_schema === "boolean") {
|
||||
config.structured_output = metadata.facts.supports_response_schema;
|
||||
}
|
||||
if (Array.isArray(metadata.facts.supported_openai_params)) {
|
||||
config.temperature = metadata.facts.supported_openai_params.includes("temperature");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function canonicalJson(value) {
|
||||
if (Array.isArray(value)) return value.map(canonicalJson);
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the configured Den model limits and capability facts from the
|
||||
* live LiteLLM model-group metadata endpoint.
|
||||
*
|
||||
* @param {ProvisionerConfig} input
|
||||
* @returns {Promise<ModelMetadataResult>}
|
||||
*/
|
||||
export async function syncProviderModelMetadata(input) {
|
||||
const denApiUrl = cleanBaseUrl(input.denApiUrl);
|
||||
const liteLlmBaseUrl = liteLlmAdminBaseUrl(input.liteLlmBaseUrl);
|
||||
const rawProviderId = requireString(input.providerId, "providerId");
|
||||
const providerId = encodeURIComponent(rawProviderId);
|
||||
const models = configuredModels(input);
|
||||
const secrets = [input.denToken, input.liteLlmMasterKey];
|
||||
const providerList = await requestJson(
|
||||
`${denApiUrl}/v1/llm-providers?scope=manageable`,
|
||||
{ headers: denHeaders(input) },
|
||||
secrets,
|
||||
);
|
||||
const provider = manageableProvider(providerList, rawProviderId);
|
||||
const providerName = requireString(provider.name, "Den provider name");
|
||||
if (!isRecord(provider.providerConfig)) {
|
||||
throw new Error(`Den provider ${rawProviderId} did not include providerConfig.`);
|
||||
}
|
||||
const currentModels = currentProviderModels(provider);
|
||||
for (const model of models) {
|
||||
if (!currentModels.some((current) => current.id === model)) {
|
||||
throw new Error(`Den provider ${rawProviderId} does not configure requested model ${model}.`);
|
||||
}
|
||||
}
|
||||
const metadataResponse = await requestJson(
|
||||
`${liteLlmBaseUrl}/model_group/info`,
|
||||
{ headers: { authorization: `Bearer ${input.liteLlmMasterKey}` } },
|
||||
secrets,
|
||||
);
|
||||
const metadata = liteLlmModelMetadata(metadataResponse, models);
|
||||
const metadataById = new Map(metadata.map((entry) => [entry.id, entry]));
|
||||
const access = currentProviderAccess(provider);
|
||||
const currentCustomConfig = {
|
||||
...provider.providerConfig,
|
||||
models: currentModels.map((model) => synchronizedModelConfig(model, undefined)),
|
||||
};
|
||||
const desiredCustomConfig = {
|
||||
...provider.providerConfig,
|
||||
models: currentModels.map((model) => synchronizedModelConfig(model, metadataById.get(model.id))),
|
||||
};
|
||||
const current = {
|
||||
name: providerName,
|
||||
source: "custom",
|
||||
customConfig: currentCustomConfig,
|
||||
credentialMode: "per_member",
|
||||
...access,
|
||||
};
|
||||
const desired = { ...current, customConfig: desiredCustomConfig };
|
||||
const summaries = metadata.map((entry) => ({
|
||||
id: entry.id,
|
||||
maxInputTokens: entry.maxInputTokens,
|
||||
maxOutputTokens: entry.maxOutputTokens,
|
||||
}));
|
||||
if (JSON.stringify(canonicalJson(current)) === JSON.stringify(canonicalJson(desired))) {
|
||||
return { action: "unchanged", models: summaries };
|
||||
}
|
||||
await requestJson(
|
||||
`${denApiUrl}/v1/llm-providers/${providerId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: denHeaders(input),
|
||||
body: JSON.stringify(desired),
|
||||
},
|
||||
secrets,
|
||||
);
|
||||
return { action: "updated", models: summaries };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {Record<string, unknown>[]}
|
||||
*/
|
||||
function memberCredentials(value) {
|
||||
if (!isRecord(value) || !Array.isArray(value.memberCredentials)) {
|
||||
throw new Error("Den member-credentials response had an invalid shape.");
|
||||
}
|
||||
return value.memberCredentials.filter(isRecord);
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function generatedKey(value) {
|
||||
if (!isRecord(value) || typeof value.key !== "string" || !value.key) {
|
||||
throw new Error("LiteLLM key generation response did not include a key.");
|
||||
}
|
||||
return value.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function externalCredentialId(value) {
|
||||
if (isRecord(value) && typeof value.token_id === "string" && value.token_id) return value.token_id;
|
||||
throw new Error("LiteLLM key generation response did not include token_id; refusing to persist an unsafe credential identifier.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a LiteLLM virtual key for every granted Den member whose binding is missing.
|
||||
*
|
||||
* @param {ProvisionerConfig} input
|
||||
* @returns {Promise<ReconcileResult>}
|
||||
*/
|
||||
export async function reconcileMemberKeys(input) {
|
||||
const modelMetadata = await syncProviderModelMetadata(input);
|
||||
const denApiUrl = cleanBaseUrl(input.denApiUrl);
|
||||
const liteLlmBaseUrl = liteLlmAdminBaseUrl(input.liteLlmBaseUrl);
|
||||
const providerId = encodeURIComponent(requireString(input.providerId, "providerId"));
|
||||
const models = configuredModels(input);
|
||||
const secrets = [input.denToken, input.liteLlmMasterKey];
|
||||
const listed = await requestJson(
|
||||
`${denApiUrl}/v1/llm-providers/${providerId}/member-credentials`,
|
||||
{ headers: denHeaders(input) },
|
||||
secrets,
|
||||
);
|
||||
/** @type {MemberCredentialSummary[]} */
|
||||
const summary = [];
|
||||
|
||||
for (const entry of memberCredentials(listed)) {
|
||||
if (entry.state !== "missing" || typeof entry.orgMembershipId !== "string") continue;
|
||||
const orgMembershipId = entry.orgMembershipId;
|
||||
const keyAlias = `openwork-${orgMembershipId}`;
|
||||
const generated = await requestJson(
|
||||
`${liteLlmBaseUrl}/key/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${input.liteLlmMasterKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
models,
|
||||
key_alias: keyAlias,
|
||||
metadata: { openwork_org_membership_id: orgMembershipId },
|
||||
}),
|
||||
},
|
||||
secrets,
|
||||
);
|
||||
const apiKey = generatedKey(generated);
|
||||
const credentialId = externalCredentialId(generated);
|
||||
const userId = isRecord(generated) && typeof generated.user_id === "string" && generated.user_id
|
||||
? generated.user_id
|
||||
: null;
|
||||
const body = {
|
||||
apiKey,
|
||||
externalCredentialId: credentialId,
|
||||
...(userId ? { externalPrincipalId: userId } : {}),
|
||||
};
|
||||
await requestJson(
|
||||
`${denApiUrl}/v1/llm-providers/${providerId}/member-credentials/${encodeURIComponent(orgMembershipId)}`,
|
||||
{ method: "PUT", headers: denHeaders(input), body: JSON.stringify(body) },
|
||||
[...secrets, apiKey],
|
||||
);
|
||||
summary.push({ orgMembershipId, action: "provisioned", externalCredentialId: credentialId });
|
||||
}
|
||||
|
||||
return { modelMetadata, memberCredentials: summary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a member's LiteLLM virtual key before marking its Den binding blocked.
|
||||
* LiteLLM v1.97 accepts the generated token_id in POST /key/block, so plaintext
|
||||
* member keys are not needed after reconciliation.
|
||||
*
|
||||
* @param {ProvisionerConfig & { orgMembershipId: string }} input
|
||||
* @returns {Promise<{ orgMembershipId: string, action: "blocked", externalCredentialId: string }>}
|
||||
*/
|
||||
export async function offboardMember(input) {
|
||||
const denApiUrl = cleanBaseUrl(input.denApiUrl);
|
||||
const liteLlmBaseUrl = liteLlmAdminBaseUrl(input.liteLlmBaseUrl);
|
||||
const providerId = encodeURIComponent(requireString(input.providerId, "providerId"));
|
||||
const orgMembershipId = requireString(input.orgMembershipId, "orgMembershipId");
|
||||
const secrets = [input.denToken, input.liteLlmMasterKey];
|
||||
const listed = await requestJson(
|
||||
`${denApiUrl}/v1/llm-providers/${providerId}/member-credentials`,
|
||||
{ headers: denHeaders(input) },
|
||||
secrets,
|
||||
);
|
||||
const binding = memberCredentials(listed).find((entry) => entry.orgMembershipId === orgMembershipId);
|
||||
const credentialId = binding && typeof binding.externalCredentialId === "string"
|
||||
? binding.externalCredentialId
|
||||
: "";
|
||||
if (!credentialId) {
|
||||
throw new Error(`Member ${orgMembershipId} does not have an externalCredentialId to block.`);
|
||||
}
|
||||
|
||||
await requestJson(
|
||||
`${liteLlmBaseUrl}/key/block`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${input.liteLlmMasterKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ key: credentialId }),
|
||||
},
|
||||
secrets,
|
||||
);
|
||||
await requestJson(
|
||||
`${denApiUrl}/v1/llm-providers/${providerId}/member-credentials/${encodeURIComponent(orgMembershipId)}/block`,
|
||||
{ method: "POST", headers: denHeaders(input), body: JSON.stringify({}) },
|
||||
secrets,
|
||||
);
|
||||
return { orgMembershipId, action: "blocked", externalCredentialId: credentialId };
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
function env(name) {
|
||||
return requireString(process.env[name], name);
|
||||
}
|
||||
|
||||
/** @returns {ProvisionerConfig} */
|
||||
function configFromEnv() {
|
||||
return {
|
||||
denApiUrl: env("OPENWORK_DEN_API_URL"),
|
||||
denToken: env("OPENWORK_DEN_TOKEN"),
|
||||
orgId: env("OPENWORK_ORG_ID"),
|
||||
providerId: env("OPENWORK_LLM_PROVIDER_ID"),
|
||||
liteLlmBaseUrl: env("LITELLM_BASE_URL"),
|
||||
liteLlmMasterKey: env("LITELLM_MASTER_KEY"),
|
||||
models: env("LITELLM_MODELS").split(",").map((model) => model.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async function main() {
|
||||
const command = process.argv[2];
|
||||
const config = configFromEnv();
|
||||
if (command === "reconcile") {
|
||||
console.log(JSON.stringify(await reconcileMemberKeys(config), null, 2));
|
||||
return;
|
||||
}
|
||||
if (command === "offboard") {
|
||||
const orgMembershipId = requireString(process.argv[3], "orgMembershipId");
|
||||
console.log(JSON.stringify(await offboardMember({ ...config, orgMembershipId }), null, 2));
|
||||
return;
|
||||
}
|
||||
throw new Error("Usage: node provision.mjs reconcile | node provision.mjs offboard <orgMembershipId>");
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
+6268
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "microsandbox-openwork-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
microsandbox = "0.6.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time"] }
|
||||
@@ -0,0 +1,69 @@
|
||||
# Microsandbox OpenWork Rust Example
|
||||
|
||||
Small standalone Rust example that starts the OpenWork micro-sandbox image with the `microsandbox` SDK, publishes the OpenWork server on a host port, persists `/workspace` and `/data` with host bind mounts, verifies `/health`, checks that `/workspaces` is `401` without a token and `200` with the client token, then keeps the sandbox alive until `Ctrl+C` while streaming the sandbox logs to your terminal.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run --manifest-path examples/microsandbox-openwork-rust/Cargo.toml
|
||||
```
|
||||
|
||||
Useful environment overrides:
|
||||
|
||||
- `OPENWORK_MICROSANDBOX_IMAGE` - OCI image reference to boot. Defaults to `openwork-microsandbox:dev`.
|
||||
- `OPENWORK_MICROSANDBOX_NAME` - sandbox name. Defaults to `openwork-microsandbox-rust`.
|
||||
- `OPENWORK_MICROSANDBOX_WORKSPACE_DIR` - host directory bind-mounted at `/workspace`. Defaults to `examples/microsandbox-openwork-rust/.state/<sandbox-name>/workspace`.
|
||||
- `OPENWORK_MICROSANDBOX_DATA_DIR` - host directory bind-mounted at `/data`. Defaults to `examples/microsandbox-openwork-rust/.state/<sandbox-name>/data`.
|
||||
- `OPENWORK_MICROSANDBOX_REPLACE` - set to `1` or `true` to replace the sandbox instead of reusing persistent state. Defaults to off.
|
||||
- `OPENWORK_MICROSANDBOX_PORT` - published host port. Defaults to `8787`.
|
||||
- `OPENWORK_CONNECT_HOST` - hostname you want clients to use. Defaults to `127.0.0.1`.
|
||||
- `OPENWORK_TOKEN` - remote-connect client token. Defaults to `microsandbox-token`.
|
||||
- `OPENWORK_HOST_TOKEN` - host/admin token. Defaults to `microsandbox-host-token`.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
OPENWORK_MICROSANDBOX_IMAGE=ghcr.io/example/openwork-microsandbox:dev \
|
||||
OPENWORK_MICROSANDBOX_WORKSPACE_DIR="$PWD/examples/microsandbox-openwork-rust/.state/demo/workspace" \
|
||||
OPENWORK_MICROSANDBOX_DATA_DIR="$PWD/examples/microsandbox-openwork-rust/.state/demo/data" \
|
||||
OPENWORK_CONNECT_HOST=127.0.0.1 \
|
||||
OPENWORK_TOKEN=some-shared-secret \
|
||||
OPENWORK_HOST_TOKEN=some-owner-secret \
|
||||
cargo run --manifest-path examples/microsandbox-openwork-rust/Cargo.toml
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
The crate includes an ignored end-to-end smoke test that:
|
||||
|
||||
- boots the microsandbox image
|
||||
- waits for `/health`
|
||||
- verifies unauthenticated `/workspaces` returns `401`
|
||||
- verifies authenticated `/workspaces` returns `200`
|
||||
- creates an OpenCode session through `/w/:workspaceId/opencode/session`
|
||||
- fetches the created session and its messages
|
||||
|
||||
Run it explicitly:
|
||||
|
||||
```bash
|
||||
OPENWORK_MICROSANDBOX_IMAGE=ttl.sh/openwork-microsandbox-11559:1d \
|
||||
cargo test --manifest-path examples/microsandbox-openwork-rust/Cargo.toml -- --ignored --nocapture
|
||||
```
|
||||
|
||||
## Persistence behavior
|
||||
|
||||
By default, the example creates and reuses two host directories under `examples/microsandbox-openwork-rust/.state/<sandbox-name>/`:
|
||||
|
||||
- `/workspace`
|
||||
- `/data`
|
||||
|
||||
That keeps OpenWork and OpenCode state around across sandbox restarts, while using normal host filesystem semantics instead of managed microsandbox named volumes.
|
||||
|
||||
If you want a clean reset, either:
|
||||
|
||||
- change the sandbox name or bind mount paths, or
|
||||
- set `OPENWORK_MICROSANDBOX_REPLACE=1`
|
||||
|
||||
## Note on local Docker images
|
||||
|
||||
`microsandbox` expects an OCI image reference. If `openwork-microsandbox:dev` only exists in your local Docker daemon, the SDK may not be able to resolve it directly. In that case, push the image to a registry or otherwise make it available as a pullable OCI image reference first, then set `OPENWORK_MICROSANDBOX_IMAGE` to that ref.
|
||||
@@ -0,0 +1,30 @@
|
||||
use anyhow::Result;
|
||||
use microsandbox::{NetworkPolicy, Sandbox};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let sandbox = Sandbox::builder("owmsb-env-debug")
|
||||
.image("ttl.sh/openwork-microsandbox-11559:1d")
|
||||
.replace()
|
||||
.memory(1024)
|
||||
.cpus(1)
|
||||
.network(|n| n.policy(NetworkPolicy::allow_all()))
|
||||
.create()
|
||||
.await?;
|
||||
|
||||
let out = sandbox
|
||||
.exec(
|
||||
"/bin/sh",
|
||||
[
|
||||
"-lc",
|
||||
"id; pwd; echo HOME=$HOME; echo USER=$USER; echo SHELL=$SHELL; env | sort | grep -E '^(HOME|USER|SHELL|XDG|PATH)=' || true; ls -ld /root /tmp /workspace /data 2>/dev/null || true; /usr/local/bin/opencode --version; rm -f /tmp/opencode.log; (/usr/local/bin/opencode serve --hostname 127.0.0.1 --port 4096 >/tmp/opencode.log 2>&1 &) ; sleep 5; echo '--- HEALTH ---'; curl -iS http://127.0.0.1:4096/health || true; echo; echo '--- SESSION CREATE ---'; curl -iS -X POST -H 'content-type: application/json' -d '{\"title\":\"debug\"}' http://127.0.0.1:4096/session || true; echo; echo '--- PROVIDER ---'; curl -iS http://127.0.0.1:4096/provider || true; echo; echo '--- OPENCODE LOG ---'; cat /tmp/opencode.log || true",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("stdout:\n{}", out.stdout()?);
|
||||
eprintln!("stderr:\n{}", out.stderr()?);
|
||||
|
||||
sandbox.stop().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use anyhow::{Context, Result};
|
||||
use microsandbox::{ExecEvent, NetworkPolicy, Sandbox};
|
||||
use reqwest::StatusCode;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let image = env::var("OPENWORK_MICROSANDBOX_IMAGE")
|
||||
.unwrap_or_else(|_| "openwork-microsandbox:dev".to_string());
|
||||
let name = env::var("OPENWORK_MICROSANDBOX_NAME")
|
||||
.unwrap_or_else(|_| "openwork-microsandbox-rust".to_string());
|
||||
let workspace_dir = env::var("OPENWORK_MICROSANDBOX_WORKSPACE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| default_bind_dir(&name, "workspace"));
|
||||
let data_dir = env::var("OPENWORK_MICROSANDBOX_DATA_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| default_bind_dir(&name, "data"));
|
||||
let replace = env_flag("OPENWORK_MICROSANDBOX_REPLACE");
|
||||
let host_port = env::var("OPENWORK_MICROSANDBOX_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(8787);
|
||||
let connect_host =
|
||||
env::var("OPENWORK_CONNECT_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let client_token =
|
||||
env::var("OPENWORK_TOKEN").unwrap_or_else(|_| "microsandbox-token".to_string());
|
||||
let host_token =
|
||||
env::var("OPENWORK_HOST_TOKEN").unwrap_or_else(|_| "microsandbox-host-token".to_string());
|
||||
|
||||
println!(
|
||||
"Starting microsandbox `{name}` from image `{image}` on http://{connect_host}:{host_port}"
|
||||
);
|
||||
|
||||
ensure_bind_dir(&workspace_dir).await?;
|
||||
ensure_bind_dir(&data_dir).await?;
|
||||
|
||||
let mut builder = Sandbox::builder(&name)
|
||||
.image(image.as_str())
|
||||
.memory(2048)
|
||||
.cpus(2)
|
||||
.env("OPENWORK_CONNECT_HOST", &connect_host)
|
||||
.env("OPENWORK_TOKEN", &client_token)
|
||||
.env("OPENWORK_HOST_TOKEN", &host_token)
|
||||
.env("OPENWORK_APPROVAL_MODE", "auto")
|
||||
.port(host_port, 8787)
|
||||
.volume("/workspace", |v| {
|
||||
v.bind(workspace_dir.to_string_lossy().as_ref())
|
||||
})
|
||||
.volume("/data", |v| v.bind(data_dir.to_string_lossy().as_ref()))
|
||||
.network(|n| n.policy(NetworkPolicy::allow_all()));
|
||||
|
||||
if replace {
|
||||
builder = builder.replace();
|
||||
}
|
||||
|
||||
let sandbox = builder
|
||||
.create()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create microsandbox from image `{image}`; if this image only exists in Docker, push it to a registry or otherwise make it available as an OCI image reference first"
|
||||
)
|
||||
})?;
|
||||
|
||||
let server = sandbox
|
||||
.exec_stream(
|
||||
"/bin/sh",
|
||||
["-lc", "/usr/local/bin/microsandbox-entrypoint.sh"],
|
||||
)
|
||||
.await
|
||||
.context("failed to start the OpenWork microsandbox entrypoint inside the VM")?;
|
||||
|
||||
let log_task = tokio::spawn(async move {
|
||||
let mut server = server;
|
||||
while let Some(event) = server.recv().await {
|
||||
match event {
|
||||
ExecEvent::Stdout(data) => print!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Stderr(data) => eprint!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Exited { code } => {
|
||||
eprintln!("microsandbox entrypoint exited with code {code}");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let base_url = format!("http://127.0.0.1:{host_port}");
|
||||
wait_for_health(&base_url).await?;
|
||||
verify_remote_connect(&base_url, &client_token).await?;
|
||||
|
||||
println!();
|
||||
println!("Health check passed: {base_url}/health");
|
||||
println!("Remote connect URL: http://{connect_host}:{host_port}");
|
||||
println!("Remote connect token: {client_token}");
|
||||
println!("Host/admin token: {host_token}");
|
||||
println!("Workspace dir: {}", workspace_dir.display());
|
||||
println!("Data dir: {}", data_dir.display());
|
||||
println!("Sandbox logs are streaming below.");
|
||||
println!("Press Ctrl+C to stop the sandbox.");
|
||||
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.context("failed waiting for Ctrl+C")?;
|
||||
println!("Stopping microsandbox `{name}`...");
|
||||
sandbox
|
||||
.stop()
|
||||
.await
|
||||
.context("failed to stop microsandbox")?;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), log_task).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
matches!(
|
||||
env::var(name).ok().as_deref(),
|
||||
Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES")
|
||||
)
|
||||
}
|
||||
|
||||
fn default_bind_dir(name: &str, suffix: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join(".state")
|
||||
.join(name)
|
||||
.join(suffix)
|
||||
}
|
||||
|
||||
async fn ensure_bind_dir(path: &Path) -> Result<()> {
|
||||
tokio::fs::create_dir_all(path)
|
||||
.await
|
||||
.with_context(|| format!("failed to create bind mount directory `{}`", path.display()))
|
||||
}
|
||||
|
||||
async fn wait_for_health(base_url: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let health_url = format!("{base_url}/health");
|
||||
|
||||
loop {
|
||||
match client.get(&health_url).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(_) | Err(_) if Instant::now() < deadline => {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(response) => {
|
||||
anyhow::bail!("health check failed with status {}", response.status());
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error).context("health check never succeeded before timeout");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_remote_connect(base_url: &str, token: &str) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let workspaces_url = format!("{base_url}/workspaces");
|
||||
|
||||
let unauthorized = client
|
||||
.get(&workspaces_url)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to query workspaces without auth")?;
|
||||
if unauthorized.status() != StatusCode::UNAUTHORIZED {
|
||||
anyhow::bail!(
|
||||
"expected unauthenticated /workspaces to return 401, got {}",
|
||||
unauthorized.status()
|
||||
);
|
||||
}
|
||||
|
||||
let authorized = client
|
||||
.get(&workspaces_url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to query workspaces with client token")?;
|
||||
if !authorized.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"expected authenticated /workspaces to succeed, got {}",
|
||||
authorized.status()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::{json, Value};
|
||||
use std::env::temp_dir;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires microsandbox runtime and a pullable OCI image"]
|
||||
async fn rust_example_smoke_test_checks_health_and_session_endpoints() -> Result<()> {
|
||||
let image = env::var("OPENWORK_MICROSANDBOX_IMAGE")
|
||||
.unwrap_or_else(|_| "ttl.sh/openwork-microsandbox-11559:1d".to_string());
|
||||
let connect_host = "127.0.0.1";
|
||||
let client_token = "some-shared-secret";
|
||||
let host_token = "some-owner-secret";
|
||||
let host_port = 28787;
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time went backwards")
|
||||
.as_millis();
|
||||
let short = unique % 1_000_000;
|
||||
let name = format!("owmsb-{short}");
|
||||
let base_dir = temp_dir().join(format!("owmsb-{short}"));
|
||||
let workspace_dir = base_dir.join("workspace");
|
||||
let data_dir = base_dir.join("data");
|
||||
|
||||
ensure_bind_dir(&workspace_dir).await?;
|
||||
ensure_bind_dir(&data_dir).await?;
|
||||
|
||||
let sandbox = Sandbox::builder(&name)
|
||||
.image(image.as_str())
|
||||
.replace()
|
||||
.memory(2048)
|
||||
.cpus(2)
|
||||
.env("OPENWORK_CONNECT_HOST", connect_host)
|
||||
.env("OPENWORK_TOKEN", client_token)
|
||||
.env("OPENWORK_HOST_TOKEN", host_token)
|
||||
.env("OPENWORK_APPROVAL_MODE", "auto")
|
||||
.port(host_port, 8787)
|
||||
.volume("/workspace", |v| {
|
||||
v.bind(workspace_dir.to_string_lossy().as_ref())
|
||||
})
|
||||
.volume("/data", |v| v.bind(data_dir.to_string_lossy().as_ref()))
|
||||
.network(|n| n.policy(NetworkPolicy::allow_all()))
|
||||
.create()
|
||||
.await?;
|
||||
|
||||
let server = sandbox
|
||||
.exec_stream(
|
||||
"/bin/sh",
|
||||
["-lc", "/usr/local/bin/microsandbox-entrypoint.sh"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let log_task = tokio::spawn(async move {
|
||||
let mut server = server;
|
||||
while let Some(event) = server.recv().await {
|
||||
match event {
|
||||
ExecEvent::Stdout(data) => print!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Stderr(data) => eprint!("{}", String::from_utf8_lossy(&data)),
|
||||
ExecEvent::Exited { code } => {
|
||||
eprintln!("test microsandbox entrypoint exited with code {code}");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let base_url = format!("http://127.0.0.1:{host_port}");
|
||||
let result = async {
|
||||
wait_for_health(&base_url).await?;
|
||||
verify_remote_connect(&base_url, client_token).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let workspaces: Value = client
|
||||
.get(format!("{base_url}/workspaces"))
|
||||
.bearer_auth(client_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let workspace_id = workspaces
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.context("missing workspace id from /workspaces")?;
|
||||
|
||||
let created: Value = client
|
||||
.post(format!("{base_url}/w/{workspace_id}/opencode/session"))
|
||||
.bearer_auth(client_token)
|
||||
.json(&json!({ "title": "Rust microsandbox smoke test" }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let session_id = created
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.context("missing session id from session create response")?;
|
||||
|
||||
client
|
||||
.get(format!(
|
||||
"{base_url}/w/{workspace_id}/opencode/session/{session_id}"
|
||||
))
|
||||
.bearer_auth(client_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
client
|
||||
.get(format!(
|
||||
"{base_url}/w/{workspace_id}/opencode/session/{session_id}/message?limit=10"
|
||||
))
|
||||
.bearer_auth(client_token)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Result::<()>::Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let stop_result = sandbox.stop().await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), log_task).await;
|
||||
stop_result?;
|
||||
result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user