Last active
November 20, 2025 20:31
-
-
Save srenault/c65241da0e97140db77489fa7bfc70ba to your computer and use it in GitHub Desktop.
LangGraph Pulumi provider
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ?? [] | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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