142 lines
6.2 KiB
TypeScript
142 lines
6.2 KiB
TypeScript
// The enablement platform over the platform's own APIs, server-side. Eligibility reads the
|
|
// rollout flags (agent_document_tools, agent_knowledge_tools) from the flag snapshot and probes
|
|
// the AI tools registry for the document tool family — discovery is the cheap plan/binding/flag
|
|
// filter, and "a working OCR endpoint does not by itself make OCR a discoverable tool". The
|
|
// managed Qdrant link goes through the providers API (the workspace services surface): an existing
|
|
// link is reused, so re-running the enablement flow never provisions a second service. The quota
|
|
// pre-check has no platform API yet (the metering slice owns it); the indexing calls themselves
|
|
// carry the typed quota_exhausted refusal at run time.
|
|
|
|
import { createRegistryClient, ToolIndexingError } from "@conusai-sdk/ai"
|
|
import { ConusAIClient, type CreateServiceRequest, type ServiceInfo } from "@conusai-sdk/node-sdk"
|
|
import type { EnablementReason } from "../contract/index.js"
|
|
import type { DocumentAiPlatform } from "../core/ports.js"
|
|
|
|
export const AGENT_DOCUMENT_TOOLS_FLAG = "agent_document_tools"
|
|
export const AGENT_KNOWLEDGE_TOOLS_FLAG = "agent_knowledge_tools"
|
|
|
|
const FLAG_KEYS = [AGENT_DOCUMENT_TOOLS_FLAG, AGENT_KNOWLEDGE_TOOLS_FLAG] as const
|
|
|
|
export interface SdkDocumentAiPlatformOptions {
|
|
/** The deployment contract's API URL and token. */
|
|
apiUrl: string
|
|
token: string
|
|
/** The organization's slug and the workspace the app links services into. */
|
|
orgSlug: string
|
|
workspaceSlug: string
|
|
/** The deployment environment, for the operator-configured Qdrant variables. */
|
|
env?: Record<string, string | undefined>
|
|
fetch?: typeof fetch
|
|
}
|
|
|
|
export function sdkDocumentAiPlatform(options: SdkDocumentAiPlatformOptions): DocumentAiPlatform {
|
|
const http = options.fetch ?? fetch
|
|
|
|
const rolloutFlagsOff = async (): Promise<string | null> => {
|
|
try {
|
|
const response = await http(new URL(`${options.apiUrl.replace(/\/+$/, "")}/flags/snapshot`), {
|
|
headers: { authorization: `Bearer ${options.token}`, accept: "application/json" },
|
|
signal: AbortSignal.timeout(5_000),
|
|
})
|
|
if (!response.ok) return null // the flags service is not the eligibility authority
|
|
const body = (await response.json()) as { flags?: Array<{ key?: string; enabled?: boolean }> }
|
|
for (const flag of body.flags ?? []) {
|
|
if (
|
|
typeof flag.key === "string" &&
|
|
(FLAG_KEYS as readonly string[]).includes(flag.key) &&
|
|
flag.enabled === false
|
|
) {
|
|
return flag.key
|
|
}
|
|
}
|
|
return null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return {
|
|
async checkEligibility(): Promise<{ ok: true } | { ok: false; reason: EnablementReason; detail?: string }> {
|
|
// The rollout flags first: a kill switch or a disabled feature refuses before any probe.
|
|
const off = await rolloutFlagsOff()
|
|
if (off !== null) return { ok: false, reason: "feature_disabled", detail: `the ${off} flag is off` }
|
|
// The plan probe: can this deployment discover the document tool family at all?
|
|
try {
|
|
const registry = createRegistryClient({
|
|
env: { CONUSAI_API_URL: options.apiUrl, CONUSAI_API_TOKEN: options.token },
|
|
fetch: http,
|
|
})
|
|
const found = await registry.tools.search("read and index documents")
|
|
if (found.length === 0) {
|
|
return { ok: false, reason: "plan_required", detail: "the ai_tools plan capability lists no document tools" }
|
|
}
|
|
return { ok: true }
|
|
} catch (error) {
|
|
if (error instanceof ToolIndexingError) {
|
|
return { ok: false, reason: "feature_disabled", detail: "the tool registry is still indexing" }
|
|
}
|
|
return {
|
|
ok: false,
|
|
reason: "plan_required",
|
|
detail: "the AI tools registry did not answer the eligibility probe",
|
|
}
|
|
}
|
|
},
|
|
|
|
async indexingQuota() {
|
|
// No pre-check API exists on the platform yet; the call-time quota refusal is the hard
|
|
// gate. Until the limit endpoint lands, the admission check answers "within quota".
|
|
return Number.MAX_SAFE_INTEGER
|
|
},
|
|
|
|
async linkManagedQdrant() {
|
|
const client = new ConusAIClient({ baseUrl: options.apiUrl, apiKey: options.token })
|
|
const path = { org_slug: options.orgSlug, workspace_slug: options.workspaceSlug }
|
|
const existing = await client.listServices({ path })
|
|
const services = (existing.data ?? []) as ServiceInfo[]
|
|
const reused = services.find((service) => service.service_type === "qdrant" && service.status !== "deleted")
|
|
if (reused) return { serviceId: String(reused.id), url: null }
|
|
const body: CreateServiceRequest = { name: "knowledge-vectors", service_type: "qdrant", parameters: {} }
|
|
const created = await client.createService({ path, body })
|
|
const status = created.response?.status ?? 0
|
|
if (status === 403) {
|
|
throw Object.assign(new Error("the plan does not allow a managed Qdrant service"), {
|
|
reason: "plan_required" as const,
|
|
})
|
|
}
|
|
if (status !== 201 || !created.data) {
|
|
throw new Error(`the providers API answered ${status} for the Qdrant link`)
|
|
}
|
|
return { serviceId: String(created.data.id), url: null }
|
|
},
|
|
|
|
async probeQdrant(url, apiKey) {
|
|
try {
|
|
const response = await http(new URL("/collections", url), {
|
|
headers: { "api-key": apiKey },
|
|
signal: AbortSignal.timeout(5_000),
|
|
})
|
|
if (response.ok) return { ok: true }
|
|
return { ok: false, detail: `the endpoint answered ${response.status}` }
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
detail:
|
|
error instanceof Error ? (error.message.split("\n")[0] ?? error.message) : "the endpoint did not answer",
|
|
}
|
|
}
|
|
},
|
|
|
|
operatorQdrant() {
|
|
// The operator-configured endpoint: exactly the variables a linked Qdrant service injects,
|
|
// set by hand in the deployment instead. The key is validated by probing; it is never
|
|
// persisted — only the endpoint is recorded.
|
|
const env = options.env ?? process.env
|
|
const url = env.QDRANT_URL?.trim()
|
|
const apiKey = env.QDRANT_API_KEY?.trim()
|
|
if (!url || !apiKey) return null
|
|
return { url, apiKey }
|
|
},
|
|
}
|
|
}
|