Skip to content

Instantly share code, notes, and snippets.

@srenault
Last active November 20, 2025 20:31
Show Gist options
  • Select an option

  • Save srenault/c65241da0e97140db77489fa7bfc70ba to your computer and use it in GitHub Desktop.

Select an option

Save srenault/c65241da0e97140db77489fa7bfc70ba to your computer and use it in GitHub Desktop.
LangGraph Pulumi provider
import {
createDeployment,
CreateDeploymentBody,
deleteDeployment,
getDeployment,
getLatestRevision,
listAssistants,
listDeployments,
listRevisions,
patchDeployment,
PatchDeploymentBody,
pollRevisionUntilDeployed,
} from "./http"
import {
Assistant,
Deployment,
Environment,
environmentEqual,
environmentToSecrets,
} from "./models"
interface LangGraphProviderInputs {
deploymentType: "prod" | "dev"
controlPlaneHost: string
name: string
apiKey: string
integrationId: string
repoUrl: string
repoRef: string
hash: string
langgraphConfigPath: string
environment: Environment
devUrl?: string
protect: boolean
installCommand?: string
buildCommand?: string
logDebug: boolean
}
export interface LangGraphProviderOutputs {
url: string
latestRevisionId: string
status: string
apiKey: string
inputs: Omit<LangGraphProviderInputs, "apiKey">
assistants: Assistant[]
}
function isInputShape(
props: LangGraphProviderOutputs | LangGraphProviderInputs,
): props is LangGraphProviderInputs {
return !("inputs" in props)
}
function isOutputShape(
props: LangGraphProviderOutputs | LangGraphProviderInputs,
): props is LangGraphProviderOutputs {
return "inputs" in props
}
export class LangGraphProvider
implements
$util.dynamic.ResourceProvider<
LangGraphProviderInputs,
LangGraphProviderOutputs
>
{
async check(
olds: LangGraphProviderInputs,
news: LangGraphProviderInputs,
): Promise<$util.dynamic.CheckResult> {
if (news.logDebug) {
console.debug(`Checking deployment ${news.name}`)
}
const failures: { property: string; reason: string }[] = []
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0
if (!isNonEmptyString(news.name))
failures.push({ property: "name", reason: "required non-empty string" })
if (!isNonEmptyString(news.deploymentType))
failures.push({
property: "deploymentType",
reason: "required non-empty string",
})
if (!isNonEmptyString(news.apiKey))
failures.push({
property: "apiKey",
reason: "required non-empty string",
})
if (!isNonEmptyString(news.integrationId))
failures.push({
property: "integrationId",
reason: "required non-empty string",
})
if (!isNonEmptyString(news.repoRef))
failures.push({
property: "repoRef",
reason: "required non-empty string",
})
if (!isNonEmptyString(news.langgraphConfigPath))
failures.push({
property: "langgraphConfigPath",
reason: "required non-empty string",
})
if (!isNonEmptyString(news.hash))
failures.push({
property: "hash",
reason: "required non-empty string",
})
if (news.installCommand && !isNonEmptyString(news.installCommand))
failures.push({
property: "installCommand",
reason: "required non-empty string",
})
if (news.buildCommand && !isNonEmptyString(news.buildCommand))
failures.push({
property: "buildCommand",
reason: "required non-empty string",
})
if (!isNonEmptyString(news.controlPlaneHost))
failures.push({
property: "controlPlaneHost",
reason: "required non-empty string",
})
try {
const repoUrlCandidate = news["repoUrl"]
if (typeof repoUrlCandidate !== "string") throw new Error("not string")
void new URL(repoUrlCandidate)
} catch {
failures.push({ property: "repoUrl", reason: "invalid URL" })
}
if (news.environment != null) {
const env = news.environment
if (env == null || typeof env !== "object" || Array.isArray(env)) {
failures.push({
property: "environment",
reason: "must be a record of key -> value",
})
} else {
for (const [key, value] of Object.entries(
env as Record<string, unknown>,
)) {
if (key.trim().length === 0) {
failures.push({
property: `environment.${key || "<empty>"}`,
reason: "invalid key",
})
continue
}
if (value == null) {
failures.push({
property: `environment.${key}`,
reason: "required",
})
continue
}
if (typeof value === "string" && value.trim().length === 0) {
failures.push({
property: `environment.${key}`,
reason:
"must be non-empty string or Pulumi Input/Output producing a string",
})
}
}
}
}
if (failures.length > 0) {
if (news.logDebug) {
console.debug(
`Check failed for deployment ${news.name}: ${failures.map((f) => f.property).join(", ")}`,
)
}
return Promise.resolve({ inputs: news, failures })
}
return Promise.resolve({
inputs: news,
})
}
async diff(
_id: $util.ID,
olds: LangGraphProviderOutputs | LangGraphProviderInputs,
news: LangGraphProviderInputs,
): Promise<$util.dynamic.DiffResult> {
if (news.logDebug) {
console.debug(`Diffing deployment ${_id}`)
}
if (news.devUrl) {
return Promise.resolve({
changes: false,
replaces: [],
deleteBeforeReplace: true,
})
}
const oldInputs = isInputShape(olds) ? olds : olds.inputs
const replaces: string[] = []
if (oldInputs.name !== news.name) replaces.push("name")
if (oldInputs.integrationId !== news.integrationId)
replaces.push("integrationId")
if (oldInputs.repoUrl !== news.repoUrl) replaces.push("repoUrl")
const changes =
replaces.length > 0 ||
oldInputs.repoRef !== news.repoRef ||
oldInputs.langgraphConfigPath !== news.langgraphConfigPath ||
oldInputs.hash !== news.hash ||
oldInputs.installCommand !== news.installCommand ||
oldInputs.buildCommand !== news.buildCommand ||
oldInputs.controlPlaneHost !== news.controlPlaneHost ||
!environmentEqual(oldInputs.environment, news.environment)
return Promise.resolve({ changes, replaces, deleteBeforeReplace: true })
}
async create(
inputs: LangGraphProviderInputs,
): Promise<$util.dynamic.CreateResult> {
if (inputs.logDebug) {
console.debug(`Creating deployment ${inputs.name}`)
}
if (inputs.devUrl) {
return {
id: inputs.name,
outs: {
url: inputs.devUrl,
latestRevisionId: "dev",
status: "DEPLOYED",
apiKey: inputs.apiKey,
inputs: inputs,
assistants: [],
} satisfies LangGraphProviderOutputs,
}
}
const body: CreateDeploymentBody = {
name: inputs.name,
source: "github",
source_config: {
integration_id: inputs.integrationId,
repo_url: inputs.repoUrl,
deployment_type: inputs.deploymentType,
build_on_push: false,
install_command: inputs.installCommand,
build_command: inputs.buildCommand,
},
source_revision_config: {
repo_ref: inputs.repoRef,
langgraph_config_path: inputs.langgraphConfigPath,
},
secrets: environmentToSecrets(inputs.environment),
}
const deploymentId = await createDeployment({
host: inputs.controlPlaneHost,
apiKey: inputs.apiKey,
body,
logDebug: inputs.logDebug,
})
try {
const latestRevision = await getLatestRevision({
host: inputs.controlPlaneHost,
apiKey: inputs.apiKey,
deploymentId,
logDebug: inputs.logDebug,
})
if (!latestRevision) throw new Error("No revision found after create")
await pollRevisionUntilDeployed({
host: inputs.controlPlaneHost,
apiKey: inputs.apiKey,
deploymentId,
revisionId: latestRevision.id,
logDebug: inputs.logDebug,
})
const deployment = await getDeployment({
host: inputs.controlPlaneHost,
deploymentId,
apiKey: inputs.apiKey,
logDebug: inputs.logDebug,
})
const deploymentUrl = deployment?.source_config?.custom_url
if (!deploymentUrl)
throw new Error("No deployment url found after create")
const assistants = await listAssistants({
apiKey: inputs.apiKey,
deploymentUrl,
logDebug: inputs.logDebug,
})
const { apiKey: _apiKey, ...inputsWithoutApiKey } = inputs
const outs = {
url: deploymentUrl,
latestRevisionId: latestRevision.id,
status: "DEPLOYED",
apiKey: inputs.apiKey,
inputs: inputsWithoutApiKey,
assistants,
} satisfies LangGraphProviderOutputs
return { id: deploymentId, outs }
} catch (err) {
console.error("Error deploying langgraph deployment", err)
await deleteDeployment({
host: inputs.controlPlaneHost,
apiKey: inputs.apiKey,
deploymentId,
projectName: inputs.name,
logDebug: inputs.logDebug,
})
throw err
}
}
async update(
id: $util.ID,
olds: LangGraphProviderOutputs | LangGraphProviderInputs,
news: LangGraphProviderInputs,
): Promise<$util.dynamic.UpdateResult> {
if (news.logDebug) {
console.debug(`Updating deployment ${id}`)
}
if (news.devUrl) {
return {
outs: {
url: news.devUrl,
latestRevisionId: "dev",
status: "DEPLOYED",
apiKey: news.apiKey,
inputs: news,
assistants: [],
} satisfies LangGraphProviderOutputs,
}
}
const patchBody: PatchDeploymentBody = {}
const sourceConfig: NonNullable<PatchDeploymentBody["source_config"]> = {}
const sourceRevisionConfig: NonNullable<
PatchDeploymentBody["source_revision_config"]
> = {}
const oldInputs = isInputShape(olds) ? olds : olds.inputs
let needsPatch = oldInputs.hash !== news.hash
let assistants: Assistant[] = []
if (isOutputShape(olds)) {
assistants = await listAssistants({
apiKey: news.apiKey,
deploymentUrl: olds.url,
logDebug: news.logDebug,
})
} else {
assistants = []
}
if (
oldInputs.repoRef !== news.repoRef ||
oldInputs.langgraphConfigPath !== news.langgraphConfigPath
) {
sourceRevisionConfig.repo_ref = news.repoRef
sourceRevisionConfig.langgraph_config_path = news.langgraphConfigPath
needsPatch = true
}
if (!environmentEqual(oldInputs.environment, news.environment)) {
patchBody.secrets = environmentToSecrets(news.environment)
needsPatch = true
}
if (oldInputs.installCommand !== news.installCommand) {
sourceConfig.install_command = news.installCommand
needsPatch = true
}
if (oldInputs.buildCommand !== news.buildCommand) {
sourceConfig.build_command = news.buildCommand
needsPatch = true
}
if (Object.keys(sourceConfig).length > 0)
patchBody.source_config = sourceConfig
if (Object.keys(sourceRevisionConfig).length > 0)
patchBody.source_revision_config = sourceRevisionConfig
const deployment = await getDeployment({
host: news.controlPlaneHost,
deploymentId: id,
apiKey: news.apiKey,
logDebug: news.logDebug,
})
const deploymentUrl = deployment?.source_config?.custom_url
if (!deploymentUrl) throw new Error("No deployment url found after update")
if (needsPatch) {
if (Object.keys(patchBody).length === 0) {
// Force a new revision to be created
patchBody.source_revision_config = {
repo_ref: news.repoRef,
langgraph_config_path: news.langgraphConfigPath,
}
}
await patchDeployment({
host: news.controlPlaneHost,
apiKey: news.apiKey,
deploymentId: id,
body: patchBody,
logDebug: news.logDebug,
})
const list = await listRevisions({
host: news.controlPlaneHost,
apiKey: news.apiKey,
deploymentId: id,
logDebug: news.logDebug,
})
const latest = list.resources[0]
if (!latest) throw new Error("No revision found after patch")
await pollRevisionUntilDeployed({
host: news.controlPlaneHost,
apiKey: news.apiKey,
deploymentId: id,
revisionId: latest.id,
logDebug: news.logDebug,
})
const { apiKey: _apiKey2, ...newsWithoutApiKey } = news
const outs = {
latestRevisionId: latest.id,
status: "DEPLOYED",
apiKey: news.apiKey,
inputs: newsWithoutApiKey,
url: deploymentUrl,
assistants,
} satisfies LangGraphProviderOutputs
return {
outs,
}
}
const { apiKey: _apiKey3, ...newsWithoutApiKey2 } = news
const outs = {
url: deploymentUrl,
latestRevisionId: isOutputShape(olds) ? olds.latestRevisionId : "unknown",
status: isOutputShape(olds) ? olds.status : "DEPLOYED",
apiKey: news.apiKey,
inputs: newsWithoutApiKey2,
assistants,
} satisfies LangGraphProviderOutputs
return {
outs,
}
}
async read(
id: $util.ID,
props?: LangGraphProviderOutputs | LangGraphProviderInputs,
): Promise<$util.dynamic.ReadResult<LangGraphProviderOutputs>> {
if (!props) {
throw new Error("Cannot read deployment without existing properties")
}
const apiKey = props.apiKey
const inputs = isInputShape(props) ? props : props.inputs
if (inputs.logDebug) {
console.debug(`Reading deployment state for ${id}`)
}
if (inputs?.devUrl) {
return {
id,
props: {
url: inputs.devUrl,
latestRevisionId: "dev",
status: "DEPLOYED",
apiKey,
inputs,
assistants: [],
} satisfies LangGraphProviderOutputs,
}
}
const deployment = await getDeploymentByName({
host: inputs.controlPlaneHost,
name: inputs.name,
apiKey: props.apiKey,
logDebug: inputs.logDebug,
})
if (!deployment) throw new Error("No deployment found after read")
const latestRevision = await getLatestRevision({
host: inputs.controlPlaneHost,
apiKey,
deploymentId: deployment.id,
logDebug: inputs.logDebug,
})
if (!latestRevision) throw new Error("No revision found after read")
const deploymentUrl = deployment?.source_config?.custom_url
if (!deploymentUrl) {
throw new Error(`No deployment URL found for deployment ${id}`)
}
const assistants = await listAssistants({
apiKey,
deploymentUrl,
logDebug: inputs.logDebug,
})
const currentState = {
url: deploymentUrl,
latestRevisionId: latestRevision.id,
status: latestRevision.status,
apiKey: props.apiKey,
inputs: {
...inputs,
hash: latestRevision.source_revision_config.repo_commit_sha,
repoRef: latestRevision.source_revision_config.repo_ref,
langgraphConfigPath:
latestRevision.source_revision_config.langgraph_config_path,
},
assistants,
} satisfies LangGraphProviderOutputs
return { id: deployment.id, props: currentState }
}
async delete(
id: $util.ID,
props: LangGraphProviderOutputs | LangGraphProviderInputs,
): Promise<void> {
const inputs = isInputShape(props) ? props : props.inputs
const apiKey = props.apiKey
if (inputs.logDebug) {
console.debug(`Deleting deployment ${id}`)
}
if (inputs.devUrl) {
return Promise.resolve()
}
if (inputs.protect) {
console.log("Resource is protected, skipping delete")
return Promise.resolve()
}
return await deleteDeployment({
host: inputs.controlPlaneHost,
apiKey,
deploymentId: id,
projectName: inputs.name,
logDebug: inputs.logDebug,
})
}
}
async function getDeploymentByName({
name,
host,
apiKey,
logDebug,
}: {
name: string
host: string
apiKey: string
logDebug: boolean
}): Promise<Deployment | undefined> {
const deployments = await listDeployments({
host,
apiKey,
logDebug,
})
return deployments.find((deployment) => deployment.name === name)
}
import {
Assistant,
Deployment,
ListRevisions,
ListRevisionsResource,
Revision,
SecretPair,
} from "./models"
const LANGSMITH_HOST = "https://api.smith.langchain.com"
const POLL_INTERVAL_SECS = 30
const MAX_WAIT_SECS = 1800
type HttpInit<_T> = {
method?: RequestInit["method"]
headers?: Record<string, string>
body?: Record<string, unknown> | string | undefined
}
async function http<T>({
url,
init,
logDebug,
}: {
url: string
init: HttpInit<T>
logDebug: boolean
}): Promise<T | undefined> {
const method = init.method ?? "GET"
const headers = init.headers ?? {}
const body =
typeof init.body === "string" ? init.body
: init.body !== undefined ? JSON.stringify(init.body)
: undefined
const res = await fetch(url, { method, headers, body })
if (logDebug) console.debug(`http: ${method} ${url}: ${res.status}`)
if (!res.ok) {
if (res.status === 404) {
return undefined
}
const text = await res.text().catch(() => "")
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`)
}
const contentType = res.headers.get("content-type") || ""
if (contentType.includes("application/json")) {
return (await res.json()) as T
}
return (await res.text()) as unknown as T
}
export async function listRevisions({
host,
apiKey,
deploymentId,
logDebug,
}: {
host: string
apiKey: string
deploymentId: string
logDebug: boolean
}): Promise<ListRevisions> {
const response = await http<ListRevisions>({
url: `${host}/v2/deployments/${deploymentId}/revisions`,
init: {
method: "GET",
headers: { "X-Api-Key": apiKey },
},
logDebug,
})
if (!response) throw new Error("No revisions found")
return response
}
export async function getLatestRevision({
host,
apiKey,
deploymentId,
logDebug,
}: {
host: string
apiKey: string
deploymentId: string
logDebug: boolean
}): Promise<ListRevisionsResource | undefined> {
const revisions = await listRevisions({
host,
apiKey,
deploymentId,
logDebug,
})
return revisions.resources[0]
}
async function pollRevisionUntil({
host,
apiKey,
deploymentId,
revisionId,
expectedStatus,
pendingStatuses,
logDebug,
intervalSecs = POLL_INTERVAL_SECS,
maxWaitSecs = MAX_WAIT_SECS,
}: {
host: string
apiKey: string
deploymentId: string
revisionId: string
expectedStatus: string
pendingStatuses: string[]
logDebug: boolean
intervalSecs?: number
maxWaitSecs?: number
}): Promise<void> {
const start = Date.now()
while (Date.now() - start < maxWaitSecs * 1000) {
const response = await http<Revision>({
url: `${host}/v2/deployments/${deploymentId}/revisions/${revisionId}`,
init: {
method: "GET",
headers: { "X-Api-Key": apiKey },
},
logDebug,
})
if (response?.status === expectedStatus) return
if (!response || !pendingStatuses.includes(response.status)) {
throw new Error(`LangGraph revision failed: ${JSON.stringify(response)}`)
}
await new Promise((r) => setTimeout(r, intervalSecs * 1000))
}
throw new Error(
`Timeout waiting for deployment ${deploymentId} revision ${revisionId} to be ${expectedStatus} after ${maxWaitSecs}s`,
)
}
export async function pollRevisionUntilDeployed({
host,
apiKey,
deploymentId,
revisionId,
logDebug,
}: {
host: string
apiKey: string
deploymentId: string
revisionId: string
logDebug: boolean
}): Promise<void> {
return pollRevisionUntil({
host,
apiKey,
deploymentId,
revisionId,
expectedStatus: "DEPLOYED",
pendingStatuses: [
"DEPLOYING",
"QUEUED",
"AWAITING_BUILD",
"BUILDING",
"AWAITING_DEPLOY",
],
logDebug,
})
}
export async function listDeployments({
host,
apiKey,
logDebug,
}: {
host: string
apiKey: string
logDebug: boolean
}): Promise<Deployment[]> {
const response = await http<{ resources: Deployment[] }>({
url: `${host}/v2/deployments`,
init: {
method: "GET",
headers: { "X-Api-Key": apiKey },
},
logDebug,
})
return response ? response.resources : []
}
export async function getDeployment({
host,
deploymentId,
apiKey,
logDebug,
}: {
host: string
deploymentId: string
apiKey: string
logDebug: boolean
}): Promise<Deployment | undefined> {
return await http<Deployment>({
url: `${host}/v2/deployments/${deploymentId}`,
init: {
method: "GET",
headers: { "X-Api-Key": apiKey },
},
logDebug,
})
}
async function getTracingProjectId({
apiKey,
projectName,
logDebug,
}: {
apiKey: string
projectName: string
logDebug: boolean
}): Promise<string | undefined> {
const response = await http<{ id: string | undefined }[]>({
url: `${LANGSMITH_HOST}/api/v1/sessions?name=${projectName}`,
init: {
method: "GET",
headers: { "X-Api-Key": apiKey },
},
logDebug,
})
return response?.[0]?.id
}
export type CreateDeploymentBody = {
name: string
source: string
source_config: {
integration_id: string
repo_url: string
deployment_type: "prod" | "dev"
build_on_push: boolean
install_command?: string
build_command?: string
}
source_revision_config: {
repo_ref: string
langgraph_config_path: string
}
secrets: SecretPair[]
}
export async function createDeployment({
host,
apiKey,
body,
logDebug,
}: {
host: string
apiKey: string
body: CreateDeploymentBody
logDebug: boolean
}): Promise<string> {
const response = await http<Deployment>({
url: `${host}/v2/deployments`,
init: {
method: "POST",
headers: {
"X-Api-Key": apiKey,
"Content-Type": "application/json",
},
body,
},
logDebug,
})
const id = response?.id
if (!id) throw new Error("No deployment created")
return id
}
export type PatchDeploymentBody = {
source_config?: {
build_on_push?: boolean
url?: string | null
resource_spec?: Record<string, unknown> | null
install_command?: string
build_command?: string
}
source_revision_config?: {
repo_ref?: string
langgraph_config_path?: string
}
secrets?: SecretPair[]
}
export async function patchDeployment({
host,
apiKey,
deploymentId,
body,
logDebug,
}: {
host: string
apiKey: string
deploymentId: string
body: PatchDeploymentBody
logDebug: boolean
}): Promise<void> {
return await http<unknown>({
url: `${host}/v2/deployments/${deploymentId}`,
init: {
method: "PATCH",
headers: {
"X-Api-Key": apiKey,
"Content-Type": "application/json",
},
body,
},
logDebug,
}).then(() => undefined)
}
export async function deleteDeployment({
host,
apiKey,
deploymentId,
projectName,
logDebug,
}: {
host: string
apiKey: string
deploymentId: string
projectName: string
logDebug: boolean
}): Promise<void> {
await http<unknown>({
url: `${host}/v2/deployments/${deploymentId}`,
init: {
method: "DELETE",
headers: { "X-Api-Key": apiKey },
},
logDebug,
})
const projectId = await getTracingProjectId({
apiKey,
projectName,
logDebug,
})
if (projectId) {
async function retry(maxRetries: number = 3) {
if (maxRetries === 0)
throw new Error("Failed to delete langsmith project")
try {
await http<unknown>({
url: `${LANGSMITH_HOST}/api/v1/sessions/${projectId}`,
init: {
method: "DELETE",
headers: { "X-Api-Key": apiKey },
},
logDebug,
})
} catch (err) {
console.error("Error deleting langsmith project", err)
await new Promise((r) => setTimeout(r, 5000))
console.debug("Retrying to delete langsmith project", maxRetries)
await retry(maxRetries - 1)
}
}
await retry()
}
}
export async function listAssistants(args: {
apiKey: string
deploymentUrl: string
logDebug: boolean
}): Promise<Assistant[]> {
const { apiKey, deploymentUrl, logDebug } = args
const response = await http<Assistant[]>({
url: `${deploymentUrl}/assistants/search`,
init: {
method: "POST",
headers: { "Content-Type": "application/json", "X-Api-Key": apiKey },
body: {}, // list all assistants
},
logDebug,
})
return response ?? []
}
export interface Assistant {
assistant_id: string
name: "personalizationAgent" | "assetsPersonalizationAgent" // Should match the name of the assistant in the langgraph config
}
export interface Deployment {
id: string
name: string
latest_revision_id?: string
status?: string
source_config?: {
custom_url?: string
}
}
export interface ListRevisionsResource {
id: string
status: string
created_at: string
source_revision_config: {
repo_ref: string
repo_commit_sha: string
langgraph_config_path: string
}
}
export interface ListRevisions {
resources: ListRevisionsResource[]
}
export interface Revision {
id: string
status: string
}
export type Environment = Record<string, string>
export type SecretValue = string
export type SecretPair = { name: string; value: SecretValue }
export function environmentEqual(
envA: Environment = {},
envB: Environment = {},
): boolean {
const envAKeys = Object.keys(envA)
const envBKeys = Object.keys(envB)
if (envAKeys.length !== envBKeys.length) return false
return Object.entries(envA).every(([key, value]) => {
return envB[key] === value
})
}
export function environmentToSecrets(environment: Environment): SecretPair[] {
return Object.entries(environment).map(([key, value]) => ({
name: key,
value: value,
}))
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment