feat: route Artificer models across upstreams (#360)
Pipelines as Code CI / agentrun-nc01-v02-ci-624654f74d847ee42a445585ed583f1955b478b6 Success

Co-authored-by: AgentRun Codex <agentrun-codex@users.noreply.github.com>
This commit is contained in:
Lyon
2026-07-15 01:07:45 +08:00
committed by GitHub
parent 08ad62ffe4
commit 624654f74d
5 changed files with 414 additions and 44 deletions
+30 -12
View File
@@ -14,10 +14,36 @@ spec:
lane: v0.2
priority: 50
providerId: NC01
backendProfile: gpt-pika
model:
model: gpt-5.6-sol
reasoningEffort: medium
upstreams:
pika:
backendProfile: gpt-pika
providerCredential:
secretRef:
namespace: agentrun-v02
name: agentrun-v02-provider-gpt-pika
keys:
- auth.json
- config.toml
grok:
backendProfile: grok
providerCredential:
secretRef:
namespace: agentrun-v02
name: agentrun-v02-provider-grok
keys:
- auth.json
- config.toml
modelRouting:
default:
upstream: pika
model: gpt-5.6-sol
reasoningEffort: medium
exact:
gpt-5.6-sol:
upstream: pika
grok:
upstream: grok
reasoningEffort: xhigh
imageRef:
kind: env-image-dockerfile
repoUrl: git@github.com:pikasTech/agentrun.git
@@ -36,14 +62,6 @@ spec:
email: agentrun-codex@users.noreply.github.com
secretScope:
allowCredentialEcho: false
providerCredentials:
- profile: gpt-pika
secretRef:
namespace: agentrun-v02
name: agentrun-v02-provider-gpt-pika
keys:
- auth.json
- config.toml
toolCredentials:
- tool: github
purpose: github-pr
+232 -17
View File
@@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { AgentRunError } from "./errors.js";
import type { AipodModelConfig, AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef } from "./types.js";
import type { AipodModelConfig, AipodModelRoute, AipodModelRouting, AipodSpec, AipodSpecRecord, AipodUpstreamConfig, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef } from "./types.js";
import { backendProfileSpec, isBackendProfile } from "./backend-profiles.js";
import { imageRefSourceSummary, validateAipodImageRef } from "./env-image-ref.js";
import { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validatePrimaryWorkspaceContract, validateResourceBundleRef, validateSessionRef, validateWorkspaceRef } from "./validation.js";
@@ -15,12 +15,20 @@ const payloadControlFields = ["backendProfile", "executionPolicy", "model", "mod
const renderControlOverrideFields = ["executionPolicy", "modelConfig", "providerCredential", "providerCredentials", "secretRef"] as const;
interface EffectiveAipodModel {
requestedModel: string | null;
model: string | null;
reasoningEffort: string | null;
modelSource: "aipod-spec-default" | "render-input-override" | null;
reasoningEffortSource: "aipod-spec-default" | "render-input-override" | null;
upstreamId: string | null;
matchedBy: "exact" | "default" | "fixed";
routingSpecHash: string | null;
backendProfile: BackendProfile;
executionPolicy: ExecutionPolicy;
}
type ProviderCredential = NonNullable<ExecutionPolicy["secretScope"]["providerCredentials"]>[number];
export function aipodSpecDirectory(): string {
return process.env.AGENTRUN_AIPOD_SPEC_DIR ?? path.join(process.cwd(), "config", "aipods");
}
@@ -86,11 +94,30 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
const name = validateAipodName(requiredString(metadata, "name"));
const labels = metadata.labels === undefined ? undefined : asRecord(metadata.labels, "aipodSpec.metadata.labels");
const spec = asRecord(record.spec, "aipodSpec.spec");
const backendProfile = normalizeBackendProfile(requiredString(spec, "backendProfile"));
const model = spec.model === undefined ? undefined : validateAipodModelConfig(spec.model, "aipodSpec.spec.model");
const imageRef = validateAipodImageRef(spec.imageRef, "aipodSpec.spec.imageRef");
const executionPolicy = validateExecutionPolicy(asRecord(spec.executionPolicy, "aipodSpec.spec.executionPolicy"));
validateAipodProviderCredential(backendProfile, executionPolicy);
const hasMultiUpstream = spec.upstreams !== undefined || spec.modelRouting !== undefined;
let backendProfile: BackendProfile | undefined;
let model: AipodModelConfig | undefined;
let upstreams: Record<string, AipodUpstreamConfig> | undefined;
let modelRouting: AipodModelRouting | undefined;
if (hasMultiUpstream) {
if (spec.upstreams === undefined || spec.modelRouting === undefined) {
throw new AgentRunError("schema-invalid", "aipodSpec.spec.upstreams and modelRouting must be declared together", { httpStatus: 400, details: { source, valuesPrinted: false } });
}
if (spec.backendProfile !== undefined || spec.model !== undefined) {
throw new AgentRunError("schema-invalid", "multi-upstream AipodSpec cannot also declare backendProfile or model", { httpStatus: 400, details: { source, valuesPrinted: false } });
}
if ((executionPolicy.secretScope.providerCredentials ?? []).length > 0) {
throw new AgentRunError("schema-invalid", "multi-upstream AipodSpec provider credentials must be declared by upstreams, not executionPolicy", { httpStatus: 400, details: { source, valuesPrinted: false } });
}
upstreams = validateAipodUpstreams(spec.upstreams, executionPolicy);
modelRouting = validateAipodModelRouting(spec.modelRouting, upstreams);
} else {
backendProfile = normalizeBackendProfile(requiredString(spec, "backendProfile"));
model = spec.model === undefined ? undefined : validateAipodModelConfig(spec.model, "aipodSpec.spec.model");
validateAipodProviderCredential(backendProfile, executionPolicy);
}
const payloadDefaults = spec.payloadDefaults === undefined ? undefined : validateAipodPayload(spec.payloadDefaults, "aipodSpec.spec.payloadDefaults");
const resourceBundleRef = validateResourceBundleRef(spec.resourceBundleRef);
const workspaceRef = isJsonRecord(spec.workspaceRef) ? validateWorkspaceRef(spec.workspaceRef) : undefined;
@@ -111,8 +138,10 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
...(stringValue(spec.lane) ? { lane: stringValue(spec.lane) as string } : {}),
...(typeof spec.priority === "number" ? { priority: spec.priority } : {}),
...(stringValue(spec.providerId) ? { providerId: stringValue(spec.providerId) as string } : {}),
backendProfile,
...(backendProfile ? { backendProfile } : {}),
...(model ? { model } : {}),
...(upstreams ? { upstreams } : {}),
...(modelRouting ? { modelRouting } : {}),
imageRef,
...(workspaceRef ? { workspaceRef } : {}),
...(spec.sessionRef !== undefined ? { sessionRef: validateSessionRef(spec.sessionRef) } : {}),
@@ -130,10 +159,19 @@ export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec
export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput = {}): RenderedAipodQueueTask {
const spec = record.spec.spec;
rejectRenderControlOverrides(input);
const backendProfile = fixedAipodBackendProfile(spec.backendProfile, input);
const effectiveModel = resolveEffectiveAipodModel(spec.model, input);
const effectiveModel = resolveEffectiveAipodRuntime(spec, input);
const backendProfile = fixedAipodBackendProfile(effectiveModel.backendProfile, input);
const imageRef = imageRefSourceSummary(spec.imageRef);
const metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash, aipodImageRef: imageRef });
const routeSnapshot = {
requestedModel: effectiveModel.requestedModel,
model: effectiveModel.model,
matchedBy: effectiveModel.matchedBy,
upstreamId: effectiveModel.upstreamId,
backendProfile,
routingSpecHash: effectiveModel.routingSpecHash,
valuesPrinted: false,
};
const metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash, aipodImageRef: imageRef, aipodModelRoute: routeSnapshot });
const inputPayload = input.payload === undefined ? undefined : validateAipodPayload(input.payload, "aipodRender.payload");
const payload = mergeRecords(spec.payloadDefaults, inputPayload);
const defaultPrompt = nonEmptyText(spec.payloadDefaults?.prompt);
@@ -152,7 +190,7 @@ export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput
providerId: input.providerId ?? spec.providerId ?? "G14",
workspaceRef: input.workspaceRef ?? spec.workspaceRef ?? { kind: "opaque", path: "." },
sessionRef,
executionPolicy: spec.executionPolicy,
executionPolicy: effectiveModel.executionPolicy,
resourceBundleRef: spec.resourceBundleRef,
payload,
references: [...(spec.references ?? []), ...(input.references ?? [])],
@@ -163,12 +201,19 @@ export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput
action: "aipod-spec-render",
aipod: summarizeAipodSpecRecord(record),
modelResolution: {
...effectiveModel,
requestedModel: effectiveModel.requestedModel,
model: effectiveModel.model,
reasoningEffort: effectiveModel.reasoningEffort,
modelSource: effectiveModel.modelSource,
reasoningEffortSource: effectiveModel.reasoningEffortSource,
upstreamId: effectiveModel.upstreamId,
matchedBy: effectiveModel.matchedBy,
routingSpecHash: effectiveModel.routingSpecHash,
modelOverridden: effectiveModel.modelSource === "render-input-override",
reasoningEffortOverridden: effectiveModel.reasoningEffortSource === "render-input-override",
backendProfile,
backendProfileSource: "aipod-spec",
providerCredential: summarizeSelectedProviderCredential(backendProfile, spec.executionPolicy),
backendProfileSource: effectiveModel.upstreamId === null ? "aipod-spec" : `model-routing-${effectiveModel.matchedBy}`,
providerCredential: summarizeSelectedProviderCredential(backendProfile, effectiveModel.executionPolicy),
valuesPrinted: false,
},
queueTask,
@@ -179,20 +224,23 @@ export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput
export function summarizeAipodSpecRecord(record: AipodSpecRecord): JsonRecord {
const spec = record.spec.spec;
const defaultRuntime = resolveEffectiveAipodRuntime(spec, {});
return {
name: record.name,
displayName: record.spec.metadata.displayName ?? record.name,
description: record.spec.metadata.description ?? null,
specHash: record.specHash,
source: record.source,
backendProfile: spec.backendProfile,
model: spec.model ?? null,
modelDefaults: summarizeAipodModelDefaults(spec.model),
backendProfile: defaultRuntime.backendProfile,
model: { model: defaultRuntime.model, reasoningEffort: defaultRuntime.reasoningEffort },
modelDefaults: summarizeAipodModelDefaults({ ...(defaultRuntime.model ? { model: defaultRuntime.model } : {}), ...(defaultRuntime.reasoningEffort ? { reasoningEffort: defaultRuntime.reasoningEffort } : {}) }),
upstreams: summarizeAipodUpstreams(spec),
modelRouting: summarizeAipodModelRouting(spec.modelRouting),
imageRef: imageRefSourceSummary(spec.imageRef),
queue: spec.queue ?? "commander",
lane: spec.lane ?? "v0.1",
providerId: spec.providerId ?? "G14",
providerCredentials: summarizeProviderCredentials(spec.executionPolicy),
providerCredentials: summarizeAipodProviderCredentials(spec),
toolCredentials: summarizeToolCredentials(spec.executionPolicy),
gitIdentity: spec.executionPolicy.gitIdentity
? { ...spec.executionPolicy.gitIdentity, source: `${record.source}#spec.executionPolicy.gitIdentity`, valuesPrinted: true }
@@ -236,6 +284,86 @@ async function loadAipodSpecFile(file: string): Promise<AipodSpecRecord> {
return { name: spec.metadata.name, spec, specHash: stableHash(spec), source: file, createdAt: info.birthtime.toISOString(), updatedAt };
}
function validateAipodUpstreams(value: unknown, executionPolicy: ExecutionPolicy): Record<string, AipodUpstreamConfig> {
const record = asRecord(value, "aipodSpec.spec.upstreams");
const entries = Object.entries(record);
if (entries.length === 0) throw new AgentRunError("schema-invalid", "aipodSpec.spec.upstreams must declare at least one upstream", { httpStatus: 400 });
const result: Record<string, AipodUpstreamConfig> = {};
const profiles = new Set<string>();
for (const [rawId, rawValue] of entries) {
const upstreamId = validateUpstreamId(rawId, `aipodSpec.spec.upstreams.${rawId}`);
const upstream = asRecord(rawValue, `aipodSpec.spec.upstreams.${upstreamId}`);
const unexpected = Object.keys(upstream).filter((key) => key !== "backendProfile" && key !== "providerCredential");
if (unexpected.length > 0) throw new AgentRunError("schema-invalid", `upstream ${upstreamId} contains unsupported fields: ${unexpected.join(", ")}`, { httpStatus: 400, details: { unexpected, valuesPrinted: false } });
const backendProfile = normalizeBackendProfile(requiredString(upstream, "backendProfile"));
if (profiles.has(backendProfile)) throw new AgentRunError("schema-invalid", `upstream backendProfile ${backendProfile} is declared more than once`, { httpStatus: 400, details: { upstreamId, backendProfile, valuesPrinted: false } });
profiles.add(backendProfile);
const providerCredential = asRecord(upstream.providerCredential, `aipodSpec.spec.upstreams.${upstreamId}.providerCredential`);
const credentialUnexpected = Object.keys(providerCredential).filter((key) => key !== "secretRef");
if (credentialUnexpected.length > 0) throw new AgentRunError("schema-invalid", `upstream ${upstreamId} providerCredential contains unsupported fields: ${credentialUnexpected.join(", ")}`, { httpStatus: 400, details: { credentialUnexpected, valuesPrinted: false } });
const declaredSecretRef = asRecord(providerCredential.secretRef, `aipodSpec.spec.upstreams.${upstreamId}.providerCredential.secretRef`);
const requiredKeys = backendProfileSpec(backendProfile)?.requiredSecretKeys ?? ["auth.json", "config.toml"];
const declaredKeys = new Set(Array.isArray(declaredSecretRef.keys) ? declaredSecretRef.keys.filter((key): key is string => typeof key === "string") : []);
const missingKeys = requiredKeys.filter((key) => !declaredKeys.has(key));
if (missingKeys.length > 0) throw new AgentRunError("secret-unavailable", `upstream ${upstreamId} provider credential is missing required keys`, { httpStatus: 400, details: { upstreamId, backendProfile, missingKeys, valuesPrinted: false } });
const policy = executionPolicyWithProviderCredential(executionPolicy, backendProfile, providerCredential.secretRef);
const credential = (policy.secretScope.providerCredentials ?? [])[0] as ProviderCredential | undefined;
if (!credential) throw new AgentRunError("secret-unavailable", `upstream ${upstreamId} has no provider credential SecretRef`, { httpStatus: 400, details: { upstreamId, backendProfile, valuesPrinted: false } });
result[upstreamId] = { backendProfile, providerCredential: { secretRef: credential.secretRef } };
}
return result;
}
function validateAipodModelRouting(value: unknown, upstreams: Record<string, AipodUpstreamConfig>): AipodModelRouting {
const record = asRecord(value, "aipodSpec.spec.modelRouting");
const unexpected = Object.keys(record).filter((key) => key !== "default" && key !== "exact");
if (unexpected.length > 0) throw new AgentRunError("schema-invalid", `aipodSpec.spec.modelRouting contains unsupported fields: ${unexpected.join(", ")}`, { httpStatus: 400, details: { unexpected, valuesPrinted: false } });
const defaultRoute = asRecord(record.default, "aipodSpec.spec.modelRouting.default");
const defaultUnexpected = Object.keys(defaultRoute).filter((key) => key !== "upstream" && key !== "model" && key !== "reasoningEffort");
if (defaultUnexpected.length > 0) throw new AgentRunError("schema-invalid", `modelRouting.default contains unsupported fields: ${defaultUnexpected.join(", ")}`, { httpStatus: 400, details: { defaultUnexpected, valuesPrinted: false } });
const defaultUpstream = validateRouteUpstream(requiredString(defaultRoute, "upstream"), upstreams, "modelRouting.default.upstream");
const defaultModel = validateModelId(defaultRoute.model, "aipodSpec.spec.modelRouting.default.model");
const defaultReasoningEffort = defaultRoute.reasoningEffort === undefined ? undefined : validateReasoningEffort(defaultRoute.reasoningEffort, "aipodSpec.spec.modelRouting.default.reasoningEffort");
const exactInput = asRecord(record.exact ?? {}, "aipodSpec.spec.modelRouting.exact");
const exact: Record<string, AipodModelRoute> = {};
for (const [rawModel, rawRoute] of Object.entries(exactInput)) {
const model = validateModelId(rawModel, `aipodSpec.spec.modelRouting.exact.${rawModel}`);
const route = asRecord(rawRoute, `aipodSpec.spec.modelRouting.exact.${model}`);
const routeUnexpected = Object.keys(route).filter((key) => key !== "upstream" && key !== "reasoningEffort");
if (routeUnexpected.length > 0) throw new AgentRunError("schema-invalid", `model route ${model} contains unsupported fields: ${routeUnexpected.join(", ")}`, { httpStatus: 400, details: { routeUnexpected, valuesPrinted: false } });
const upstream = validateRouteUpstream(requiredString(route, "upstream"), upstreams, `modelRouting.exact.${model}.upstream`);
const reasoningEffort = route.reasoningEffort === undefined ? undefined : validateReasoningEffort(route.reasoningEffort, `aipodSpec.spec.modelRouting.exact.${model}.reasoningEffort`);
exact[model] = { upstream, ...(reasoningEffort ? { reasoningEffort } : {}) };
}
return {
default: { upstream: defaultUpstream, model: defaultModel, ...(defaultReasoningEffort ? { reasoningEffort: defaultReasoningEffort } : {}) },
exact,
};
}
function validateRouteUpstream(value: string, upstreams: Record<string, AipodUpstreamConfig>, label: string): string {
const upstream = validateUpstreamId(value, label);
if (!upstreams[upstream]) throw new AgentRunError("schema-invalid", `${label} references unknown upstream ${upstream}`, { httpStatus: 400, details: { upstream, available: Object.keys(upstreams).sort(), valuesPrinted: false } });
return upstream;
}
function validateUpstreamId(value: string, label: string): string {
const upstream = value.trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(upstream)) throw new AgentRunError("schema-invalid", `${label} must be a lowercase slug`, { httpStatus: 400 });
return upstream;
}
function executionPolicyWithProviderCredential(executionPolicy: ExecutionPolicy, profile: BackendProfile, secretRef: unknown): ExecutionPolicy {
const validatedSecretRefInput = asRecord(secretRef, `providerCredential.${profile}.secretRef`);
return validateExecutionPolicy({
...executionPolicy,
secretScope: {
...executionPolicy.secretScope,
providerCredentials: [{ profile, secretRef: validatedSecretRefInput }],
},
});
}
function validateAipodProviderCredential(profile: BackendProfile, policy: ExecutionPolicy): void {
const matching = (policy.secretScope.providerCredentials ?? []).filter((item) => item.profile === profile);
if (matching.length !== 1) {
@@ -264,6 +392,48 @@ function summarizeProviderCredentials(policy: ExecutionPolicy): JsonRecord {
return { count: items.length, profiles: items.map((item) => item.profile), items, valuesPrinted: false };
}
function summarizeAipodProviderCredentials(spec: AipodSpec["spec"]): JsonRecord {
if (!spec.upstreams) return summarizeProviderCredentials(spec.executionPolicy);
const items = Object.entries(spec.upstreams).map(([upstreamId, upstream]) => ({
upstreamId,
profile: upstream.backendProfile,
name: upstream.providerCredential.secretRef.name,
namespace: upstream.providerCredential.secretRef.namespace ?? null,
keys: upstream.providerCredential.secretRef.keys ?? [],
valuesPrinted: false,
}));
return { count: items.length, profiles: items.map((item) => item.profile), items, valuesPrinted: false };
}
function summarizeAipodUpstreams(spec: AipodSpec["spec"]): JsonRecord | null {
if (!spec.upstreams) return null;
const items = Object.entries(spec.upstreams).map(([upstreamId, upstream]) => ({
upstreamId,
backendProfile: upstream.backendProfile,
providerCredential: {
secretRef: {
name: upstream.providerCredential.secretRef.name,
namespace: upstream.providerCredential.secretRef.namespace ?? null,
keys: upstream.providerCredential.secretRef.keys ?? [],
valuesPrinted: false,
},
valuesPrinted: false,
},
valuesPrinted: false,
}));
return { count: items.length, ids: items.map((item) => item.upstreamId), items, valuesPrinted: false };
}
function summarizeAipodModelRouting(modelRouting: AipodModelRouting | undefined): JsonRecord | null {
if (!modelRouting) return null;
return {
default: { ...modelRouting.default, valuesPrinted: false },
exact: Object.entries(modelRouting.exact).map(([model, route]) => ({ model, upstream: route.upstream, reasoningEffort: route.reasoningEffort ?? null, valuesPrinted: false })),
routingSpecHash: stableHash(modelRouting),
valuesPrinted: false,
};
}
function summarizeToolCredentials(policy: ExecutionPolicy): JsonRecord {
const items = (policy.secretScope.toolCredentials ?? []).map((item) => ({ tool: item.tool, purpose: item.purpose ?? null, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], projection: item.projection, valuesPrinted: false }));
return { count: items.length, tools: items.map((item) => item.tool), items, valuesPrinted: false };
@@ -343,7 +513,48 @@ function fixedAipodBackendProfile(profile: BackendProfile, input: RenderAipodInp
return profile;
}
function resolveEffectiveAipodModel(defaults: AipodModelConfig | undefined, input: RenderAipodInput): EffectiveAipodModel {
function resolveEffectiveAipodRuntime(spec: AipodSpec["spec"], input: RenderAipodInput): EffectiveAipodModel {
if (spec.upstreams && spec.modelRouting) {
const modelOverride = renderModelOverride(input, "model", validateModelId);
const reasoningOverride = renderModelOverride(input, "reasoningEffort", validateReasoningEffort);
const requestedModel = modelOverride.provided ? modelOverride.value : spec.modelRouting.default.model;
if (!requestedModel) throw new AgentRunError("schema-invalid", "multi-upstream AipodSpec did not resolve a model", { httpStatus: 400 });
const exactRoute = modelOverride.provided ? spec.modelRouting.exact[requestedModel] : undefined;
const matchedBy = exactRoute ? "exact" : "default";
const upstreamId = exactRoute?.upstream ?? spec.modelRouting.default.upstream;
const upstream = spec.upstreams[upstreamId];
if (!upstream) throw new AgentRunError("schema-invalid", `model route resolved unknown upstream ${upstreamId}`, { httpStatus: 400, details: { upstreamId, valuesPrinted: false } });
const routeReasoningEffort = exactRoute?.reasoningEffort ?? spec.modelRouting.default.reasoningEffort ?? null;
const reasoningEffort = reasoningOverride.provided ? reasoningOverride.value : routeReasoningEffort;
const executionPolicy = executionPolicyWithProviderCredential(spec.executionPolicy, upstream.backendProfile, upstream.providerCredential.secretRef);
return {
requestedModel,
model: requestedModel,
reasoningEffort,
modelSource: modelOverride.provided ? "render-input-override" : "aipod-spec-default",
reasoningEffortSource: reasoningOverride.provided ? "render-input-override" : reasoningEffort ? "aipod-spec-default" : null,
upstreamId,
matchedBy,
routingSpecHash: stableHash(spec.modelRouting),
backendProfile: upstream.backendProfile,
executionPolicy,
};
}
const backendProfile = spec.backendProfile;
if (!backendProfile) throw new AgentRunError("schema-invalid", "single-upstream AipodSpec requires backendProfile", { httpStatus: 400 });
const fixed = resolveFixedAipodModel(spec.model, input);
return {
...fixed,
requestedModel: fixed.model,
upstreamId: null,
matchedBy: "fixed",
routingSpecHash: null,
backendProfile,
executionPolicy: spec.executionPolicy,
};
}
function resolveFixedAipodModel(defaults: AipodModelConfig | undefined, input: RenderAipodInput): Pick<EffectiveAipodModel, "model" | "reasoningEffort" | "modelSource" | "reasoningEffortSource"> {
const modelOverride = renderModelOverride(input, "model", validateModelId);
const reasoningOverride = renderModelOverride(input, "reasoningEffort", validateReasoningEffort);
const defaultModel = defaults?.model ?? null;
@@ -387,6 +598,10 @@ function applyEffectiveModelPayload(payload: JsonRecord, effective: EffectiveAip
reasoningEffort: effective.reasoningEffort,
modelSource: effective.modelSource,
reasoningEffortSource: effective.reasoningEffortSource,
requestedModel: effective.requestedModel,
upstreamId: effective.upstreamId,
matchedBy: effective.matchedBy,
routingSpecHash: effective.routingSpecHash,
valuesPrinted: false,
};
}
+24 -1
View File
@@ -118,8 +118,10 @@ export interface AipodSpec extends JsonRecord {
lane?: string;
priority?: number;
providerId?: string;
backendProfile: BackendProfile;
backendProfile?: BackendProfile;
model?: AipodModelConfig;
upstreams?: Record<string, AipodUpstreamConfig>;
modelRouting?: AipodModelRouting;
imageRef: AipodImageRef;
workspaceRef?: WorkspaceRef;
sessionRef?: SessionRef | null;
@@ -137,6 +139,27 @@ export interface AipodModelConfig extends JsonRecord {
reasoningEffort?: string;
}
export interface AipodUpstreamConfig extends JsonRecord {
backendProfile: BackendProfile;
providerCredential: {
secretRef: SecretRef;
};
}
export interface AipodModelRoute extends JsonRecord {
upstream: string;
reasoningEffort?: string;
}
export interface AipodModelRouting extends JsonRecord {
default: {
upstream: string;
model: string;
reasoningEffort?: string;
};
exact: Record<string, AipodModelRoute>;
}
export interface AipodSpecRecord extends JsonRecord {
name: string;
spec: AipodSpec;
+69 -7
View File
@@ -68,9 +68,16 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(shownModelDefaults.modelSource, "aipod-spec-default");
assert.equal(shownModelDefaults.reasoningEffortSource, "aipod-spec-default");
const shownProviderCredentials = shownItem.providerCredentials as JsonRecord;
assert.equal(shownProviderCredentials.count, 2);
const shownProviderCredential = (shownProviderCredentials.items as JsonRecord[]).find((item) => item.profile === "gpt-pika");
assert.equal(shownProviderCredential?.name, "agentrun-v02-provider-gpt-pika");
assert.equal(shownProviderCredential?.namespace, "agentrun-v02");
const shownGrokCredential = (shownProviderCredentials.items as JsonRecord[]).find((item) => item.profile === "grok");
assert.equal(shownGrokCredential?.name, "agentrun-v02-provider-grok");
assert.equal(shownGrokCredential?.namespace, "agentrun-v02");
const shownRouting = shownItem.modelRouting as JsonRecord;
assert.deepEqual(shownRouting.default, { upstream: "pika", model: "gpt-5.6-sol", reasoningEffort: "medium", valuesPrinted: false });
assert.equal((shownRouting.exact as JsonRecord[]).some((item) => item.model === "grok" && item.upstream === "grok" && item.reasoningEffort === "xhigh"), true);
const shownToolCredentials = shownItem.toolCredentials as JsonRecord;
assert.equal(shownToolCredentials.count, 4);
assert.equal(shownToolCredentials.valuesPrinted, false);
@@ -121,7 +128,10 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(modelResolution.modelSource, "aipod-spec-default");
assert.equal(modelResolution.reasoningEffortSource, "aipod-spec-default");
assert.equal(modelResolution.backendProfile, "gpt-pika");
assert.equal(modelResolution.backendProfileSource, "aipod-spec");
assert.equal(modelResolution.upstreamId, "pika");
assert.equal(modelResolution.matchedBy, "default");
assert.equal(modelResolution.backendProfileSource, "model-routing-default");
assert.match(String(modelResolution.routingSpecHash), /^[a-f0-9]{64}$/u);
assert.equal(modelResolution.valuesPrinted, false);
const policy = task.executionPolicy as JsonRecord;
assert.equal(policy.timeoutMs, 7_200_000, "Artificer timeout budget must come from the owning AipodSpec YAML");
@@ -211,9 +221,28 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(modelOverridePayload.reasoningEffort, "high");
assert.equal(modelOverrideResolution.modelSource, "render-input-override");
assert.equal(modelOverrideResolution.reasoningEffortSource, "render-input-override");
assert.equal(modelOverrideResolution.upstreamId, "pika");
assert.equal(modelOverrideResolution.matchedBy, "default");
assert.deepEqual((modelOverrideResolution.providerCredential as JsonRecord).secretRef, disclosedProviderCredential.secretRef);
assert.deepEqual(((modelOverrideTask.executionPolicy as JsonRecord).secretScope as JsonRecord).providerCredentials, providerCredentials);
const grokOverride = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "route to grok", model: "grok" }) as JsonRecord;
const grokTask = grokOverride.queueTask as JsonRecord;
const grokPayload = grokTask.payload as JsonRecord;
const grokResolution = grokOverride.modelResolution as JsonRecord;
const grokCredentials = (((grokTask.executionPolicy as JsonRecord).secretScope as JsonRecord).providerCredentials as JsonRecord[]);
assert.equal(grokTask.backendProfile, "grok");
assert.equal(grokPayload.model, "grok");
assert.equal(grokPayload.reasoningEffort, "xhigh");
assert.equal(grokResolution.upstreamId, "grok");
assert.equal(grokResolution.matchedBy, "exact");
assert.equal(grokResolution.backendProfileSource, "model-routing-exact");
assert.equal(grokCredentials.length, 1);
assert.equal(grokCredentials[0]?.profile, "grok");
assert.equal(((grokCredentials[0]?.secretRef as JsonRecord).name), "agentrun-v02-provider-grok");
assert.equal(grokCredentials.some((item) => item.profile === "gpt-pika"), false);
assertNoSecretLeak(grokOverride);
const reasoningOverride = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "override reasoning", reasoningEffort: "high" }) as JsonRecord;
const reasoningOverridePayload = (reasoningOverride.queueTask as JsonRecord).payload as JsonRecord;
const reasoningOverrideResolution = reasoningOverride.modelResolution as JsonRecord;
@@ -235,9 +264,35 @@ const selfTest: SelfTestCase = async (context) => {
const originalSpecDocument = parseYaml(await readFile(artificerSpecFile, "utf8")) as JsonRecord;
const missingCredentialDocument = structuredClone(originalSpecDocument);
const missingCredentialPolicy = ((missingCredentialDocument.spec as JsonRecord).executionPolicy as JsonRecord);
((missingCredentialPolicy.secretScope as JsonRecord).providerCredentials) = [];
assert.throws(() => validateAipodSpec(missingCredentialDocument, "selftest-missing-gpt-pika-credential"), /requires exactly one matching provider credential/u);
delete (((missingCredentialDocument.spec as JsonRecord).upstreams as JsonRecord).grok as JsonRecord).providerCredential;
assert.throws(() => validateAipodSpec(missingCredentialDocument, "selftest-missing-grok-credential"), /providerCredential/u);
const missingKeyDocument = structuredClone(originalSpecDocument);
((((missingKeyDocument.spec as JsonRecord).upstreams as JsonRecord).grok as JsonRecord).providerCredential as JsonRecord).secretRef = {
namespace: "agentrun-v02",
name: "agentrun-v02-provider-grok",
keys: ["auth.json"],
};
assert.throws(() => validateAipodSpec(missingKeyDocument, "selftest-missing-grok-key"), /missing required keys/u);
const unknownRouteDocument = structuredClone(originalSpecDocument);
((((unknownRouteDocument.spec as JsonRecord).modelRouting as JsonRecord).exact as JsonRecord).grok as JsonRecord).upstream = "missing";
assert.throws(() => validateAipodSpec(unknownRouteDocument, "selftest-unknown-upstream-route"), /references unknown upstream missing/u);
const duplicateProfileDocument = structuredClone(originalSpecDocument);
((((duplicateProfileDocument.spec as JsonRecord).upstreams as JsonRecord).grok as JsonRecord).backendProfile) = "gpt-pika";
assert.throws(() => validateAipodSpec(duplicateProfileDocument, "selftest-duplicate-upstream-profile"), /declared more than once/u);
const mixedModeDocument = structuredClone(originalSpecDocument);
((mixedModeDocument.spec as JsonRecord).backendProfile) = "gpt-pika";
assert.throws(() => validateAipodSpec(mixedModeDocument, "selftest-mixed-upstream-mode"), /cannot also declare backendProfile or model/u);
const legacySingleUpstreamDocument = structuredClone(originalSpecDocument);
const legacySpec = legacySingleUpstreamDocument.spec as JsonRecord;
delete legacySpec.upstreams;
delete legacySpec.modelRouting;
legacySpec.backendProfile = "gpt-pika";
legacySpec.model = { model: "gpt-5.6-sol", reasoningEffort: "medium" };
(((legacySpec.executionPolicy as JsonRecord).secretScope as JsonRecord).providerCredentials) = [{
profile: "gpt-pika",
secretRef: { namespace: "agentrun-v02", name: "agentrun-v02-provider-gpt-pika", keys: ["auth.json", "config.toml"] },
}];
assert.equal(validateAipodSpec(legacySingleUpstreamDocument, "selftest-legacy-single-upstream").spec.backendProfile, "gpt-pika");
const payloadDefaultsBypassDocument = structuredClone(originalSpecDocument);
((payloadDefaultsBypassDocument.spec as JsonRecord).payloadDefaults as JsonRecord).modelConfig = { model: "bypass" };
assert.throws(() => validateAipodSpec(payloadDefaultsBypassDocument, "selftest-payload-defaults-bypass"), /cannot set Aipod control fields: modelConfig/u);
@@ -288,6 +343,13 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal((sendPlanData.modelResolution as JsonRecord).reasoningEffort, "medium");
assert.equal((((sendPlanData.modelResolution as JsonRecord).providerCredential as JsonRecord).secretRef as JsonRecord).name, "agentrun-v02-provider-gpt-pika");
const grokSendPlan = await runCliJson(context, server.baseUrl, ["sessions", "send", "--aipod", "Artificer", "--prompt", "dry-run grok session", "--model", "grok", "--no-runner-job", "--dry-run"]);
const grokSendPlanData = grokSendPlan.data as JsonRecord;
assert.equal(grokSendPlanData.profile, "grok");
assert.equal((grokSendPlanData.modelResolution as JsonRecord).upstreamId, "grok");
assert.equal((grokSendPlanData.modelResolution as JsonRecord).matchedBy, "exact");
assert.equal((((grokSendPlanData.modelResolution as JsonRecord).providerCredential as JsonRecord).secretRef as JsonRecord).name, "agentrun-v02-provider-grok");
const cancelledSessionId = "sess_artificer_cancelled_cli_selftest";
const cancelledConversationId = "conv_artificer_cancelled_cli_selftest";
const cancelledThreadId = "thread_artificer_cancelled_cli_selftest";
@@ -344,8 +406,8 @@ const selfTest: SelfTestCase = async (context) => {
const durableTask = await client.post("/api/v1/queue/tasks", rendered.queueTask) as JsonRecord;
const changedSpecDocument = structuredClone(originalSpecDocument);
((changedSpecDocument.spec as JsonRecord).model as JsonRecord).model = "gpt-5.7-terra";
((changedSpecDocument.spec as JsonRecord).model as JsonRecord).reasoningEffort = "low";
(((changedSpecDocument.spec as JsonRecord).modelRouting as JsonRecord).default as JsonRecord).model = "gpt-5.7-terra";
(((changedSpecDocument.spec as JsonRecord).modelRouting as JsonRecord).default as JsonRecord).reasoningEffort = "low";
await writeFile(artificerSpecFile, stringifyYaml(changedSpecDocument), "utf8");
const changedRender = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "new yaml default" }) as JsonRecord;
assert.equal(((changedRender.queueTask as JsonRecord).payload as JsonRecord).model, "gpt-5.7-terra");
@@ -353,7 +415,7 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal((storedTask.payload as JsonRecord).model, "gpt-5.6-sol");
assert.equal((storedTask.payload as JsonRecord).reasoningEffort, "medium");
assert.deepEqual((storedTask.payload as JsonRecord).modelConfig, taskPayload.modelConfig);
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-v02-runtime-authority", "aipod-spec-gpt-pika-secret-namespace", "aipod-spec-model-defaults", "aipod-spec-model-reasoning-overrides", "aipod-spec-model-override-keeps-provider", "aipod-spec-control-override-fail-closed", "aipod-spec-payload-control-bypass-fail-closed", "aipod-spec-missing-gpt-pika-credential", "aipod-spec-durable-task-model-snapshot", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-artificer-private-repository-override", "aipod-spec-artificer-private-repository-override-runner-projection", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "session-send-aipod-model-dry-run", "session-send-aipod-cancelled-durable-identity", "session-send-aipod-boundary-conflict-fail-closed", "aipod-cli-help"] };
return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-v02-runtime-authority", "aipod-spec-multi-upstream-secret-summary", "aipod-spec-legacy-single-upstream-compatible", "aipod-spec-model-defaults", "aipod-spec-model-reasoning-overrides", "aipod-spec-model-default-fallback", "aipod-spec-grok-exact-route", "aipod-spec-selected-provider-only", "aipod-spec-invalid-route-fail-closed", "aipod-spec-control-override-fail-closed", "aipod-spec-payload-control-bypass-fail-closed", "aipod-spec-durable-task-model-snapshot", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-artificer-private-repository-override", "aipod-spec-artificer-private-repository-override-runner-projection", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "session-send-aipod-model-dry-run", "session-send-aipod-grok-dry-run", "session-send-aipod-cancelled-durable-identity", "session-send-aipod-boundary-conflict-fail-closed", "aipod-cli-help"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -35,7 +35,7 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(task.providerId, "NC01");
assert.equal(modelResolution.model, "gpt-5.6-sol");
assert.equal(modelResolution.reasoningEffort, "medium");
await assertDurableRetryModel(context.tmp, task as unknown as CreateQueueTaskInput);
await assertDurableRetryModel(context.tmp, task as unknown as CreateQueueTaskInput, { profile: "gpt-pika", model: "gpt-5.6-sol", reasoningEffort: "medium", matchedBy: "default" });
const runtimeExecutionPolicy = structuredClone(task.executionPolicy as JsonRecord);
((runtimeExecutionPolicy.secretScope as JsonRecord).toolCredentials) = [];
@@ -83,14 +83,64 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(turnStart?.payload.model, "gpt-5.6-sol");
assert.equal(turnStart?.payload.reasoningEffort, "medium");
assert.equal(turnStart?.payload.reasoningProtocolField, "effort");
assertNoSecretLeak({ rendered, result, events });
return { name: "artificer-model-selection", tests: ["artificer-gpt-pika-default-render", "artificer-durable-retry-model", "artificer-gpt-pika-model-runtime", "artificer-reasoning-effort-turn-start", "artificer-model-selection-visible"] };
const grokRendered = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "verify grok model selection", model: "grok" }) as JsonRecord;
const grokTask = grokRendered.queueTask as JsonRecord;
const grokResolution = grokRendered.modelResolution as JsonRecord;
assert.equal(grokTask.backendProfile, "grok");
assert.equal(grokResolution.model, "grok");
assert.equal(grokResolution.reasoningEffort, "xhigh");
assert.equal(grokResolution.upstreamId, "grok");
assert.equal(grokResolution.matchedBy, "exact");
await assertDurableRetryModel(context.tmp, grokTask as unknown as CreateQueueTaskInput, { profile: "grok", model: "grok", reasoningEffort: "xhigh", matchedBy: "exact" });
const grokRuntimePolicy = structuredClone(grokTask.executionPolicy as JsonRecord);
((grokRuntimePolicy.secretScope as JsonRecord).toolCredentials) = [];
const grokRun = await client.post("/api/v1/runs", {
tenantId: grokTask.tenantId,
projectId: grokTask.projectId,
providerId: grokTask.providerId,
backendProfile: grokTask.backendProfile,
workspaceRef: { kind: "host-path", path: context.workspace },
sessionRef: null,
executionPolicy: grokRuntimePolicy,
resourceBundleRef: null,
traceSink: { kind: "self-test", source: "artificer-grok-model-selection" },
}) as JsonRecord;
const grokCommand = await client.post(`/api/v1/runs/${String(grokRun.id)}/commands`, {
type: "turn",
payload: grokTask.payload,
idempotencyKey: "selftest-artificer-grok-model-selection",
}) as JsonRecord;
const grokResult = await runOnce({
managerUrl: server.baseUrl,
runId: String(grokRun.id),
commandId: String(grokCommand.id),
backendProfile: "grok",
codexCommand: context.fakeCodexCommand,
codexArgs: context.fakeCodexArgs,
codexHome: context.codexHome,
env: {
CODEX_HOME: context.codexHome,
AGENTRUN_FAKE_CODEX_MODE: "require-explicit-model-effort",
AGENTRUN_FAKE_CODEX_EXPECTED_MODEL: "grok",
AGENTRUN_FAKE_CODEX_EXPECTED_REASONING_EFFORT: "xhigh",
},
oneShot: true,
});
assert.equal(grokResult.terminalStatus, "completed");
const grokEvents = await client.get(`/api/v1/runs/${String(grokRun.id)}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: JsonRecord }> };
const grokStarting = grokEvents.items?.find((event) => event.type === "backend_status" && event.payload.phase === "codex-app-server-starting");
assert.equal(grokStarting?.payload.backendProfile, "grok");
assert.equal((grokStarting?.payload.modelSelection as JsonRecord).model, "grok");
assert.equal((grokStarting?.payload.modelSelection as JsonRecord).reasoningEffort, "xhigh");
assertNoSecretLeak({ rendered, result, events, grokRendered, grokResult, grokEvents });
return { name: "artificer-model-selection", tests: ["artificer-gpt-pika-default-render", "artificer-grok-exact-render", "artificer-durable-retry-model", "artificer-durable-retry-upstream", "artificer-gpt-pika-model-runtime", "artificer-grok-model-runtime", "artificer-reasoning-effort-turn-start", "artificer-model-selection-visible"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
};
async function assertDurableRetryModel(tmp: string, input: CreateQueueTaskInput): Promise<void> {
async function assertDurableRetryModel(tmp: string, input: CreateQueueTaskInput, expected: { profile: string; model: string; reasoningEffort: string; matchedBy: string }): Promise<void> {
const store = new MemoryAgentRunStore();
const retryInput = { ...structuredClone(input), sessionRef: null };
assert.ok(retryInput.executionPolicy);
@@ -139,9 +189,11 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
});
const command = retried.command as JsonRecord;
assert.deepEqual(command.payload, task.payload);
assert.equal((command.payload as JsonRecord).model, "gpt-5.6-sol");
assert.equal((command.payload as JsonRecord).reasoningEffort, "medium");
assert.equal(((command.payload as JsonRecord).modelConfig as JsonRecord).modelSource, "aipod-spec-default");
assert.ok(retried.run);
assert.equal(retried.run.backendProfile, expected.profile);
assert.equal((command.payload as JsonRecord).model, expected.model);
assert.equal((command.payload as JsonRecord).reasoningEffort, expected.reasoningEffort);
assert.equal(((command.payload as JsonRecord).modelConfig as JsonRecord).matchedBy, expected.matchedBy);
}
export default selfTest;