Files
pikasTech-agentrun/src/mgr/provider-profiles.ts
T
2026-06-05 18:34:13 +08:00

532 lines
23 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { AgentRunError } from "../common/errors.js";
import { backendProfileSpec, backendProfileSpecs } from "../common/backend-profiles.js";
import type { AgentRunStore } from "./store.js";
import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue } from "../common/types.js";
import { asRecord, validateBackendProfile } from "../common/validation.js";
import { redactJson, redactText } from "../common/redaction.js";
import { createKubernetesRunnerJob, type RunnerJobDefaults } from "./kubernetes-runner-job.js";
import { buildRunResult } from "./result.js";
import { runnerJobStatusSummary } from "./runner-job-status.js";
const defaultNamespace = "agentrun-v01";
const credentialAnnotationPrefix = "agentrun.pikastech.local/provider-profile";
export interface ProviderProfileOptions {
namespace?: string;
kubectlCommand?: string;
}
export interface ProviderProfileValidationOptions extends ProviderProfileOptions {
store: AgentRunStore;
sourceCommit: string;
runnerJobDefaults?: Partial<RunnerJobDefaults>;
}
interface ProfileConfig {
model: string;
providerName: string;
baseUrl: string;
envKey: string;
wireApi: "responses";
displayName: string;
}
interface RenderedConfig {
configToml: string;
configSummary: JsonRecord;
}
export async function listProviderProfiles(options: ProviderProfileOptions = {}): Promise<JsonRecord> {
const items = await Promise.all(backendProfileSpecs.map((spec) => providerProfileStatus(spec.profile, options)));
return { items, count: items.length, valuesPrinted: false };
}
export async function showProviderProfile(profile: string, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
return providerProfileStatus(validateBackendProfile(profile), options);
}
export async function setProviderProfileCredential(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
const profile = validateBackendProfile(profileValue);
const spec = requiredSpec(profile);
const record = asRecord(body ?? {}, "providerProfileCredential");
const apiKey = stringField(record, "apiKey");
if (apiKey.length < 8) throw new AgentRunError("secret-unavailable", "apiKey is too short", { httpStatus: 400 });
const delegatedBy = delegatedBySummary(record.delegatedBy);
const rendered = renderCredential(apiKey, await renderedConfigForWrite(profile, record, options));
const namespace = profileNamespace(options);
const secretPatch: JsonRecord = {
metadata: {
labels: {
"app.kubernetes.io/part-of": "agentrun",
"agentrun.pikastech.local/profile": profile,
},
annotations: {
[`${credentialAnnotationPrefix}-profile`]: profile,
[`${credentialAnnotationPrefix}-credential-hash-suffix`]: shortHash(rendered.authJson),
[`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(rendered.config.configToml),
[`${credentialAnnotationPrefix}-updated-at`]: new Date().toISOString(),
"kubectl.kubernetes.io/last-applied-configuration": null,
...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}),
},
},
type: "Opaque",
data: {
"auth.json": base64Data(rendered.authJson),
"config.toml": base64Data(rendered.config.configToml),
},
};
const applied = await kubectlPatchSecret(spec.defaultSecretName, namespace, secretPatch, options.kubectlCommand ?? "kubectl");
return {
action: "provider-profile-credential-updated",
mutation: true,
profile,
configured: true,
secretRef: secretRefSummary(profile, namespace),
resourceVersion: objectPath(applied, ["metadata", "resourceVersion"]),
credentialHashSuffix: shortHash(rendered.authJson),
configHashSuffix: shortHash(rendered.config.configToml),
updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(),
configSummary: rendered.config.configSummary,
delegatedBy,
requiresExternalBridgeUpdate: profile === "deepseek",
valuesPrinted: false,
pollCommands: {
show: `./scripts/agentrun provider-profiles show ${profile}`,
validate: `./scripts/agentrun provider-profiles validate ${profile} --wait --timeout-ms 120000`,
},
};
}
export async function validateProviderProfile(profileValue: string, body: unknown, options: ProviderProfileValidationOptions): Promise<JsonRecord> {
const profile = validateBackendProfile(profileValue);
const namespace = profileNamespace(options);
const runnerDefaults = runnerDefaultsForValidation(options, namespace);
const record = body === null ? {} : asRecord(body ?? {}, "providerProfileValidation");
const validationId = `val_${randomUUID().replace(/-/gu, "")}`;
const prompt = typeof record.prompt === "string" && record.prompt.trim().length > 0 ? record.prompt.trim() : `只回复 AGENTRUN_PROVIDER_PROFILE_OK_${profile.replace(/[^a-z0-9]/gu, "_").toUpperCase()}`;
const run = await options.store.createRun({
tenantId: "hwlab",
projectId: "pikasTech/HWLAB",
workspaceRef: { kind: "opaque", path: "/home/agentrun/agentrun-source" },
providerId: "G14",
backendProfile: profile,
executionPolicy: validationExecutionPolicy(profile, namespace),
traceSink: null,
sessionRef: { sessionId: `sess_${validationId}`, metadata: { purpose: "provider-profile-validation", validationId, profile } },
resourceBundleRef: null,
});
const command = await options.store.createCommand(run.id, { type: "turn", payload: { prompt }, idempotencyKey: validationId });
const runnerJob = await createKubernetesRunnerJob({
store: options.store,
runId: run.id,
input: {
commandId: command.id,
idempotencyKey: validationId,
attemptId: `attempt_${validationId.slice(4, 16)}`,
},
defaults: runnerDefaults,
});
return validationResponse({ profile, validationId, runId: run.id, commandId: command.id, runnerJob, status: "running" });
}
export async function getProviderProfileValidation(profileValue: string, validationId: string, options: { store: AgentRunStore }): Promise<JsonRecord> {
const profile = validateBackendProfile(profileValue);
if (!/^val_[a-f0-9]{32}$/u.test(validationId)) throw new AgentRunError("schema-invalid", `validationId ${validationId} is not valid`, { httpStatus: 400 });
const session = await options.store.getSession(`sess_${validationId}`);
const runId = session?.lastRunId ?? session?.activeRunId;
const commandId = session?.lastCommandId ?? session?.activeCommandId;
if (!runId || !commandId) throw new AgentRunError("schema-invalid", `validation ${validationId} was not found`, { httpStatus: 404 });
const [result, jobs, events] = await Promise.all([
buildRunResult(options.store, runId, commandId),
options.store.listRunnerJobs(runId, commandId),
options.store.listEvents(runId, 0, 500),
]);
const status = validationStatus(result as JsonRecord);
return {
validationId,
profile,
runId,
commandId,
status,
result: result as JsonRecord,
runnerJobs: jobs.map((job) => runnerJobStatusSummary(job, events)),
lastSeq: events.at(-1)?.seq ?? 0,
valuesPrinted: false,
};
}
async function providerProfileStatus(profile: BackendProfile, options: ProviderProfileOptions): Promise<JsonRecord> {
const spec = requiredSpec(profile);
const namespace = profileNamespace(options);
const secretRef = secretRefSummary(profile, namespace);
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
if (!secret) {
return {
profile,
backendKind: spec.backendKind,
configured: false,
failureKind: "secret-unavailable",
secretRef,
resourceVersion: null,
credentialHashSuffix: null,
configHashSuffix: null,
updatedAt: null,
valuesPrinted: false,
};
}
const data = asOptionalRecord(secret.data);
const annotations = asOptionalRecord(asOptionalRecord(secret.metadata)?.annotations);
return {
profile,
backendKind: spec.backendKind,
configured: hasRequiredKeys(data, spec.requiredSecretKeys),
failureKind: hasRequiredKeys(data, spec.requiredSecretKeys) ? null : "secret-unavailable",
secretRef,
resourceVersion: stringPath(secret, ["metadata", "resourceVersion"]),
credentialHashSuffix: hashDataKey(data, "auth.json") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-credential-hash-suffix`]),
configHashSuffix: hashDataKey(data, "config.toml") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-config-hash-suffix`]),
updatedAt: stringPath(annotations, [`${credentialAnnotationPrefix}-updated-at`]) ?? stringPath(secret, ["metadata", "creationTimestamp"]),
keyPresence: Object.fromEntries(spec.requiredSecretKeys.map((key) => [key, typeof data?.[key] === "string"])) as JsonRecord,
lastValidation: null,
valuesPrinted: false,
};
}
function renderCredential(apiKey: string, config: RenderedConfig): { authJson: string; config: RenderedConfig } {
const authJson = `${JSON.stringify(authPayload(apiKey))}\n`;
return {
authJson,
config,
};
}
function authPayload(apiKey: string): JsonRecord {
return { OPENAI_API_KEY: apiKey };
}
function renderConfigToml(config: ProfileConfig): string {
return [
`model_provider = ${tomlString(config.providerName)}`,
`model = ${tomlString(config.model)}`,
`review_model = ${tomlString(config.model)}`,
"disable_response_storage = true",
"network_access = \"enabled\"",
"model_context_window = 128000",
"model_auto_compact_token_limit = 110000",
"approvals_reviewer = \"user\"",
"",
`[model_providers.${config.providerName}]`,
`name = ${tomlString(config.displayName)}`,
`base_url = ${tomlString(config.baseUrl)}`,
`wire_api = ${tomlString(config.wireApi)}`,
"requires_openai_auth = true",
"",
"[projects.\"/root\"]",
"trust_level = \"trusted\"",
"",
"[projects.\"/home/agentrun/workspaces\"]",
"trust_level = \"trusted\"",
"",
"[tui.model_availability_nux]",
`${tomlString(config.model)} = 4`,
"",
"[notice]",
"hide_full_access_warning = true",
"",
].join("\n");
}
async function renderedConfigForWrite(profile: BackendProfile, record: JsonRecord, options: ProviderProfileOptions): Promise<RenderedConfig> {
if (record.config !== undefined && record.config !== null) return renderedConfigFromProfileConfig(configField(record.config, profile), "request-config");
const existing = await existingConfigToml(profile, options);
if (existing && existingConfigAllowed(profile, existing)) {
return { configToml: existing, configSummary: { source: "existing-secret", preserved: true, valuesPrinted: false } };
}
return renderedConfigFromProfileConfig(defaultConfig(profile), "profile-default");
}
function renderedConfigFromProfileConfig(config: ProfileConfig, source: string): RenderedConfig {
return {
configToml: renderConfigToml(config),
configSummary: {
source,
model: config.model,
providerName: config.providerName,
baseUrl: config.baseUrl,
authField: config.envKey,
wireApi: config.wireApi,
valuesPrinted: false,
},
};
}
async function existingConfigToml(profile: BackendProfile, options: ProviderProfileOptions): Promise<string | null> {
const spec = requiredSpec(profile);
const namespace = profileNamespace(options);
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
const data = asOptionalRecord(secret?.data);
const value = data?.["config.toml"];
if (typeof value !== "string" || value.length === 0) return null;
try {
return Buffer.from(value, "base64").toString("utf8");
} catch {
return null;
}
}
function existingConfigAllowed(profile: BackendProfile, configToml: string): boolean {
if (configToml.trim().length === 0) return false;
if (profile === "deepseek" && configToml.includes("hyueapi.com")) return false;
if (profile === "deepseek" && !configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local")) return false;
return true;
}
function configField(value: unknown, profile: BackendProfile): ProfileConfig {
const defaults = defaultConfig(profile);
if (value === undefined || value === null) return defaults;
const record = asRecord(value, "config");
const model = optionalString(record.model) ?? defaults.model;
const baseUrl = optionalString(record.baseUrl) ?? defaults.baseUrl;
const providerName = optionalString(record.providerName) ?? defaults.providerName;
const envKey = optionalString(record.envKey) ?? defaults.envKey;
validateBaseUrl(profile, baseUrl);
if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(envKey)) throw new AgentRunError("schema-invalid", "config.envKey must be an uppercase env name", { httpStatus: 400 });
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u.test(providerName)) throw new AgentRunError("schema-invalid", "config.providerName must be a provider identifier", { httpStatus: 400 });
return { ...defaults, model, baseUrl, providerName, envKey };
}
function defaultConfig(profile: BackendProfile): ProfileConfig {
if (profile === "deepseek") {
return {
model: "deepseek-chat",
providerName: "OpenAI",
baseUrl: "http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1",
envKey: "OPENAI_API_KEY",
wireApi: "responses",
displayName: "OpenAI",
};
}
if (profile === "minimax-m3") {
return {
model: "MiniMax-M3",
providerName: "minimax",
baseUrl: "https://api.minimaxi.com/v1",
envKey: "OPENAI_API_KEY",
wireApi: "responses",
displayName: "MiniMax",
};
}
return {
model: "gpt-5-codex",
providerName: "openai",
baseUrl: "https://api.openai.com/v1",
envKey: "OPENAI_API_KEY",
wireApi: "responses",
displayName: "OpenAI",
};
}
function validateBaseUrl(profile: BackendProfile, value: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new AgentRunError("schema-invalid", "config.baseUrl must be a valid URL", { httpStatus: 400 });
}
if (profile === "deepseek" && url.hostname === "hyueapi.com") {
throw new AgentRunError("tenant-policy-denied", "deepseek profile must use HWLAB Moon Bridge, not hyueapi.com", { httpStatus: 403 });
}
if (profile === "deepseek" && url.hostname !== "hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local") {
throw new AgentRunError("tenant-policy-denied", "deepseek profile baseUrl must point to HWLAB v0.2 Moon Bridge", { httpStatus: 403 });
}
}
function validationExecutionPolicy(profile: BackendProfile, namespace: string): ExecutionPolicy {
const spec = requiredSpec(profile);
return {
sandbox: "workspace-write",
approval: "never",
timeoutMs: 120_000,
network: "enabled",
secretScope: {
allowCredentialEcho: false,
providerCredentials: [{ profile, secretRef: { namespace, name: spec.defaultSecretName, keys: [...spec.requiredSecretKeys] } }],
},
};
}
function runnerDefaultsForValidation(options: ProviderProfileValidationOptions, namespace: string): RunnerJobDefaults {
return {
namespace,
managerUrl: options.runnerJobDefaults?.managerUrl ?? process.env.AGENTRUN_INTERNAL_MGR_URL ?? `http://agentrun-mgr.${namespace}.svc.cluster.local:8080`,
image: options.runnerJobDefaults?.image ?? process.env.AGENTRUN_RUNNER_IMAGE ?? "",
sourceCommit: options.runnerJobDefaults?.sourceCommit ?? options.sourceCommit,
serviceAccountName: options.runnerJobDefaults?.serviceAccountName ?? process.env.AGENTRUN_RUNNER_SERVICE_ACCOUNT ?? "agentrun-v01-runner",
kubectlCommand: options.runnerJobDefaults?.kubectlCommand ?? options.kubectlCommand ?? "kubectl",
};
}
function validationResponse(input: { profile: BackendProfile; validationId: string; runId: string; commandId: string; runnerJob: JsonRecord; status: string }): JsonRecord {
const jobIdentity = asOptionalRecord(input.runnerJob.jobIdentity);
const jobName = typeof jobIdentity?.name === "string" ? jobIdentity.name : typeof input.runnerJob.jobName === "string" ? input.runnerJob.jobName : null;
return {
action: "provider-profile-validation-started",
validationId: input.validationId,
profile: input.profile,
runId: input.runId,
commandId: input.commandId,
jobName,
status: input.status,
pollUrl: `/api/v1/provider-profiles/${encodeURIComponent(input.profile)}/validations/${encodeURIComponent(input.validationId)}`,
runnerJob: input.runnerJob,
valuesPrinted: false,
};
}
function validationStatus(result: JsonRecord): string {
const status = typeof result.status === "string" ? result.status : null;
const terminalStatus = typeof result.terminalStatus === "string" ? result.terminalStatus : null;
if (terminalStatus === "completed" || status === "completed") return "completed";
if (terminalStatus === "cancelled" || status === "cancelled") return "cancelled";
if (terminalStatus === "failed" || terminalStatus === "blocked" || status === "failed" || status === "blocked") return "failed";
return "running";
}
function secretRefSummary(profile: BackendProfile, namespace: string): JsonRecord {
const spec = requiredSpec(profile);
return { namespace, name: spec.defaultSecretName, keys: [...spec.requiredSecretKeys], valuesPrinted: false };
}
function requiredSpec(profile: BackendProfile) {
const spec = backendProfileSpec(profile);
if (!spec) throw new AgentRunError("schema-invalid", `backendProfile ${profile} is not supported in v0.1`, { httpStatus: 400 });
return spec;
}
function profileNamespace(options: ProviderProfileOptions): string {
return options.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? defaultNamespace;
}
async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: string): Promise<JsonRecord | null> {
const result = await runKubectl(kubectlCommand, ["get", "secret", name, "-n", namespace, "-o", "json"]);
if (result.code !== 0) {
const failureText = `${result.stderr}\n${result.stdout}`;
if (/notfound|not found|not-found/iu.test(failureText)) return null;
throw new AgentRunError("infra-failed", `kubectl get secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
}
return parseKubectlObject(result.stdout, "secret");
}
async function kubectlPatchSecret(name: string, namespace: string, patch: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "--patch-file", "/dev/stdin", "-o", "json"], `${JSON.stringify(patch)}\n`);
if (result.code !== 0) {
throw new AgentRunError("infra-failed", `kubectl patch provider profile secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
}
return parseKubectlObject(result.stdout, "patched secret", { redactSecretData: true });
}
async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
const child = spawn(kubectlCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
child.stdin.end(stdin ?? "");
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code, signal) => resolve({ code, signal }));
}).catch((error: unknown) => {
throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
});
return { ...result, stdout, stderr };
}
function parseKubectlObject(stdout: string, label: string, options: { redactSecretData?: boolean } = {}): JsonRecord {
try {
const parsed = JSON.parse(stdout) as unknown;
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return options.redactSecretData ? redactSecretObject(parsed as JsonRecord) : parsed as JsonRecord;
} catch (error) {
throw new AgentRunError("infra-failed", `kubectl returned invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { stdoutPreview: label.includes("secret") ? "REDACTED" : redactText(stdout.slice(0, 1000)) } });
}
throw new AgentRunError("infra-failed", `kubectl returned non-object JSON for ${label}`, { httpStatus: 502 });
}
function redactSecretObject(object: JsonRecord): JsonRecord {
const copy = JSON.parse(JSON.stringify(object)) as JsonRecord;
if (copy.data) copy.data = "REDACTED";
if (copy.stringData) copy.stringData = "REDACTED";
return copy;
}
function hasRequiredKeys(data: JsonRecord | null, keys: readonly string[]): boolean {
return keys.every((key) => typeof data?.[key] === "string" && String(data[key]).length > 0);
}
function hashDataKey(data: JsonRecord | null, key: string): string | null {
const value = data?.[key];
if (typeof value !== "string" || value.length === 0) return null;
try {
return shortHash(Buffer.from(value, "base64").toString("utf8"));
} catch {
return shortHash(value);
}
}
function base64Data(value: string): string {
return Buffer.from(value, "utf8").toString("base64");
}
function shortHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 12);
}
function stringField(record: JsonRecord, key: string): string {
const value = record[key];
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 });
return value.trim();
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function objectPath(record: JsonRecord, path: string[]): JsonValue | null {
let current: unknown = record;
for (const key of path) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as JsonRecord)[key];
}
return current as JsonValue;
}
function stringPath(record: JsonRecord | null | undefined, path: string[]): string | null {
if (!record) return null;
const value = objectPath(record, path);
return typeof value === "string" ? value : null;
}
function asOptionalRecord(value: unknown): JsonRecord | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
}
function delegatedBySummary(value: unknown): JsonRecord | null {
if (value === undefined || value === null) return null;
const record = asRecord(value, "delegatedBy");
const system = optionalString(record.system) ?? "operator-cli";
return {
system,
userId: optionalString(record.userId) ?? null,
username: optionalString(record.username) ?? null,
requestId: optionalString(record.requestId) ?? null,
valuesPrinted: false,
};
}
function tomlString(value: string): string {
return JSON.stringify(value);
}