Merge pull request 'feat: unified provider/model configuration (Phase 1)' (#111) from feat/110-phase1-config-layer into main

This commit is contained in:
2026-05-08 02:15:23 +00:00
16 changed files with 331 additions and 89 deletions
@@ -13,19 +13,7 @@ import {
templateTsconfigJson, templateTsconfigJson,
} from "./templates.js"; } from "./templates.js";
import type { CmdInitTemplateSuccess } from "./types.js"; import type { CmdInitTemplateSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean { function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
return Array.isArray(workspaces) && workspaces.includes("templates/*"); return Array.isArray(workspaces) && workspaces.includes("templates/*");
@@ -0,0 +1,15 @@
import { err, ok, type Result } from "@uncaged/workflow";
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
export function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
@@ -5,19 +5,7 @@ import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "../../fs-utils.js"; import { pathExists } from "../../fs-utils.js";
import type { CmdInitWorkspaceSuccess } from "./types.js"; import type { CmdInitWorkspaceSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function validateWorkspaceSegment(name: string): Result<void, string> {
if (name.length === 0) {
return err("workspace name must not be empty");
}
if (name === "." || name === "..") {
return err("invalid workspace name");
}
if (name.includes("/") || name.includes("\\")) {
return err("workspace name must not contain path separators");
}
return ok(undefined);
}
function rootPackageJson(workspaceName: string): string { function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify( return `${JSON.stringify(
@@ -6,7 +6,7 @@ import { join } from "node:path";
import { getExtractProvider } from "../src/extract-provider.js"; import { getExtractProvider } from "../src/extract-provider.js";
describe("getExtractProvider", () => { describe("getExtractProvider", () => {
test("returns provider when config.extract is present", async () => { test("returns provider when config.models.extract is present", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-")); const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
try { try {
await mkdir(root, { recursive: true }); await mkdir(root, { recursive: true });
@@ -14,10 +14,13 @@ describe("getExtractProvider", () => {
join(root, "workflow.yaml"), join(root, "workflow.yaml"),
`config: `config:
maxDepth: 3 maxDepth: 3
extract: providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: literal-key apiKey: literal-key
models:
default: dashscope/qwen-turbo
extract: dashscope/qwen-plus
workflows: {} workflows: {}
`, `,
"utf8", "utf8",
@@ -61,10 +64,13 @@ workflows: {}
join(root, "workflow.yaml"), join(root, "workflow.yaml"),
`config: `config:
maxDepth: 1 maxDepth: 1
extract: providers:
p:
baseUrl: https://example.com baseUrl: https://example.com
model: m
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
models:
default: p/other-model
extract: p/m
workflows: {} workflows: {}
`, `,
"utf8", "utf8",
+19 -10
View File
@@ -105,10 +105,13 @@ describe("workflow registry", () => {
const yaml = ` const yaml = `
config: config:
maxDepth: 3 maxDepth: 3
extract: providers:
dashscope:
baseUrl: https://example.com/v1 baseUrl: https://example.com/v1
model: qwen-plus
apiKey: secret-key apiKey: secret-key
models:
default: dashscope/qwen-turbo
extract: dashscope/qwen-plus
workflows: workflows:
solve-issue: solve-issue:
hash: SPVR4BDMSGC1W hash: SPVR4BDMSGC1W
@@ -125,9 +128,10 @@ workflows:
return; return;
} }
expect(r.value.config.maxDepth).toBe(3); expect(r.value.config.maxDepth).toBe(3);
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1"); expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1");
expect(r.value.config.extract.model).toBe("qwen-plus"); expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
expect(r.value.config.extract.apiKey).toBe("secret-key"); expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
}); });
test("parses config apiKey env: prefix from process.env", () => { test("parses config apiKey env: prefix from process.env", () => {
@@ -137,10 +141,13 @@ workflows:
const yaml = ` const yaml = `
config: config:
maxDepth: 1 maxDepth: 1
extract: providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: env:WF_REGISTRY_TEST_API_KEY apiKey: env:WF_REGISTRY_TEST_API_KEY
models:
default: dashscope/qwen-plus
extract: dashscope/qwen-plus
workflows: {} workflows: {}
`; `;
const r = parseWorkflowRegistryYaml(yaml); const r = parseWorkflowRegistryYaml(yaml);
@@ -148,7 +155,7 @@ workflows: {}
if (!r.ok) { if (!r.ok) {
return; return;
} }
expect(r.value.config?.extract.apiKey).toBe("from-env"); expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env");
} finally { } finally {
if (prev === undefined) { if (prev === undefined) {
delete process.env.WF_REGISTRY_TEST_API_KEY; delete process.env.WF_REGISTRY_TEST_API_KEY;
@@ -165,10 +172,12 @@ workflows: {}
const yaml = ` const yaml = `
config: config:
maxDepth: 1 maxDepth: 1
extract: providers:
p:
baseUrl: https://example.com baseUrl: https://example.com
model: m
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
models:
default: p/m
workflows: {} workflows: {}
`; `;
const r = parseWorkflowRegistryYaml(yaml); const r = parseWorkflowRegistryYaml(yaml);
@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test";
import { resolveModel } from "../src/config/resolve-model.js";
import type { WorkflowConfig } from "../src/registry/index.js";
function sampleConfig(): WorkflowConfig {
return {
maxDepth: 3,
providers: {
dashscope: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "secret",
},
other: {
baseUrl: "https://other.example/v1",
apiKey: "k2",
},
},
models: {
default: "dashscope/qwen-plus",
extract: "other/foo/bar-model",
},
};
}
describe("resolveModel", () => {
test("uses explicit scene mapping", () => {
const config = sampleConfig();
const r = resolveModel(config, "extract");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://other.example/v1");
expect(r.value.apiKey).toBe("k2");
expect(r.value.model).toBe("foo/bar-model");
});
test("falls back to models.default when scene is missing", () => {
const config = sampleConfig();
const r = resolveModel(config, "unknown-scene");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.model).toBe("qwen-plus");
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
});
test("errs when scene missing and no default", () => {
const config: WorkflowConfig = {
maxDepth: 1,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
extract: "p/m",
},
};
const r = resolveModel(config, "other");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no model mapping");
expect(r.error).toContain("default");
});
test("errs when provider is unknown", () => {
const config: WorkflowConfig = {
maxDepth: 1,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "missing/m",
},
};
const r = resolveModel(config, "any");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("unknown provider");
});
test("errs on invalid model reference shape", () => {
const config: WorkflowConfig = {
maxDepth: 1,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "no-slash-model",
},
};
const r = resolveModel(config, "x");
expect(r.ok).toBe(false);
});
});
@@ -140,12 +140,17 @@ describe("workflowAsAgent", () => {
...reg.value, ...reg.value,
config: { config: {
maxDepth: 2, maxDepth: 2,
extract: { providers: {
local: {
baseUrl: "http://127.0.0.1:9", baseUrl: "http://127.0.0.1:9",
model: "m",
apiKey: "k", apiKey: "k",
}, },
}, },
models: {
default: "local/m",
extract: "local/m",
},
},
}; };
const wr = await writeWorkflowRegistry(root, withCfg); const wr = await writeWorkflowRegistry(root, withCfg);
expect(wr.ok).toBe(true); expect(wr.ok).toBe(true);
+3
View File
@@ -0,0 +1,3 @@
export { resolveModel } from "./resolve-model.js";
export { splitProviderModelRef } from "./split-provider-model-ref.js";
export type { ProviderConfig, ResolvedModel } from "./types.js";
@@ -0,0 +1,30 @@
import type { WorkflowConfig } from "../registry/index.js";
import { err, ok, type Result } from "../util/index.js";
import { splitProviderModelRef } from "./split-provider-model-ref.js";
import type { ResolvedModel } from "./types.js";
/** Resolves scene → provider endpoint + model using {@link WorkflowConfig.providers} and {@link WorkflowConfig.models}. */
export function resolveModel(config: WorkflowConfig, scene: string): Result<ResolvedModel, string> {
const models = config.models;
let ref = models[scene] ?? null;
if (ref === null) {
ref = models.default ?? null;
}
if (ref === null) {
return err(`no model mapping for scene "${scene}" and no models.default fallback`);
}
const split = splitProviderModelRef(ref);
if (!split.ok) {
return split;
}
const { providerName, modelName } = split.value;
const provider = config.providers[providerName] ?? null;
if (provider === null) {
return err(`unknown provider "${providerName}" referenced by scene "${scene}"`);
}
return ok({
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
model: modelName,
});
}
@@ -0,0 +1,17 @@
import { err, ok, type Result } from "../util/index.js";
/** Parses `providerName/modelName` references used in {@link WorkflowConfig.models}. */
export function splitProviderModelRef(
ref: string,
): Result<{ providerName: string; modelName: string }, string> {
const idx = ref.indexOf("/");
if (idx <= 0 || idx === ref.length - 1) {
return err(`invalid model reference "${ref}": expected providerName/modelName`);
}
const providerName = ref.slice(0, idx);
const modelName = ref.slice(idx + 1);
if (providerName === "" || modelName === "") {
return err(`invalid model reference "${ref}": expected providerName/modelName`);
}
return ok({ providerName, modelName });
}
+10
View File
@@ -0,0 +1,10 @@
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
};
export type ResolvedModel = {
baseUrl: string;
apiKey: string;
model: string;
};
+7 -2
View File
@@ -1,3 +1,4 @@
import { resolveModel } from "./config/index.js";
import type { WorkflowConfig } from "./registry/index.js"; import type { WorkflowConfig } from "./registry/index.js";
import { readWorkflowRegistry } from "./registry/index.js"; import { readWorkflowRegistry } from "./registry/index.js";
import type { LlmProvider } from "./types.js"; import type { LlmProvider } from "./types.js";
@@ -12,7 +13,7 @@ export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): numbe
return config.maxDepth; return config.maxDepth;
} }
/** Loads `config.extract` from workflow.yaml (apiKey already resolved at registry parse time). */ /** Loads the LLM provider for scene `extract` from workflow.yaml (`config.models` + `config.providers`; apiKey resolved at registry parse time). */
export async function getExtractProvider( export async function getExtractProvider(
storageRoot: string | undefined, storageRoot: string | undefined,
): Promise<Result<LlmProvider, string>> { ): Promise<Result<LlmProvider, string>> {
@@ -25,7 +26,11 @@ export async function getExtractProvider(
if (cfg === null) { if (cfg === null) {
return err("workflow registry has no global config section"); return err("workflow registry has no global config section");
} }
const ex = cfg.extract; const resolved = resolveModel(cfg, "extract");
if (!resolved.ok) {
return resolved;
}
const ex = resolved.value;
return ok({ return ok({
baseUrl: ex.baseUrl, baseUrl: ex.baseUrl,
apiKey: ex.apiKey, apiKey: ex.apiKey,
+5 -1
View File
@@ -28,6 +28,11 @@ export {
serializeMerkleNode, serializeMerkleNode,
type ThreadMerklePayload, type ThreadMerklePayload,
} from "./cas/index.js"; } from "./cas/index.js";
export {
type ProviderConfig,
type ResolvedModel,
resolveModel,
} from "./config/index.js";
export { export {
buildForkPlan, buildForkPlan,
createThreadPauseGate, createThreadPauseGate,
@@ -60,7 +65,6 @@ export {
} from "./extract/index.js"; } from "./extract/index.js";
export { getExtractProvider } from "./extract-provider.js"; export { getExtractProvider } from "./extract-provider.js";
export { export {
type ExtractProviderConfig,
getRegisteredWorkflow, getRegisteredWorkflow,
listRegisteredWorkflowNames, listRegisteredWorkflowNames,
parseWorkflowRegistryYaml, parseWorkflowRegistryYaml,
-1
View File
@@ -11,7 +11,6 @@ export {
writeWorkflowRegistry, writeWorkflowRegistry,
} from "./registry.js"; } from "./registry.js";
export type { export type {
ExtractProviderConfig,
WorkflowConfig, WorkflowConfig,
WorkflowHistoryEntry, WorkflowHistoryEntry,
WorkflowRegistryEntry, WorkflowRegistryEntry,
@@ -1,49 +1,107 @@
import { err, ok, type Result } from "../util/index.js"; import { type ProviderConfig, splitProviderModelRef } from "../config/index.js";
import { createLogger, err, ok, type Result } from "../util/index.js";
import type { import type {
ExtractProviderConfig,
WorkflowConfig, WorkflowConfig,
WorkflowHistoryEntry, WorkflowHistoryEntry,
WorkflowRegistryEntry, WorkflowRegistryEntry,
WorkflowRegistryFile, WorkflowRegistryFile,
} from "./types.js"; } from "./types.js";
function resolveRegistryApiKey(raw: string): Result<string, Error> { const registryNormalizeLog = createLogger({ sink: { kind: "stderr" } });
function resolveRegistryApiKey(raw: string, ctx: string): Result<string, Error> {
if (raw.startsWith("env:")) { if (raw.startsWith("env:")) {
const name = raw.slice("env:".length); const name = raw.slice("env:".length);
if (name === "") { if (name === "") {
return err(new Error('config.extract.apiKey "env:" reference must name a variable')); return err(new Error(`${ctx}: "env:" apiKey reference must name a variable`));
} }
const value = process.env[name]; const value = process.env[name];
if (value === undefined) { if (value === undefined) {
return err(new Error(`config.extract.apiKey: environment variable "${name}" is not set`)); return err(new Error(`${ctx}: environment variable "${name}" is not set`));
} }
return ok(value); return ok(value);
} }
return ok(raw); return ok(raw);
} }
function normalizeExtractProviderConfig(raw: unknown): Result<ExtractProviderConfig, Error> { function normalizeProviderEntry(name: string, entryRaw: unknown): Result<ProviderConfig, Error> {
if (raw === null || typeof raw !== "object") { if (name === "") {
return err(new Error('registry config must contain an "extract" mapping')); return err(new Error("config.providers must not contain an empty provider name"));
} }
const e = raw as Record<string, unknown>; if (entryRaw === null || typeof entryRaw !== "object" || Array.isArray(entryRaw)) {
return err(new Error(`config.providers.${name} must be a mapping`));
}
const e = entryRaw as Record<string, unknown>;
const baseUrl = e.baseUrl; const baseUrl = e.baseUrl;
const model = e.model;
const apiKeyRaw = e.apiKey; const apiKeyRaw = e.apiKey;
if (typeof baseUrl !== "string" || baseUrl === "") { if (typeof baseUrl !== "string" || baseUrl === "") {
return err(new Error("config.extract.baseUrl must be a non-empty string")); return err(new Error(`config.providers.${name}.baseUrl must be a non-empty string`));
}
if (typeof model !== "string" || model === "") {
return err(new Error("config.extract.model must be a non-empty string"));
} }
if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") { if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") {
return err(new Error("config.extract.apiKey must be a non-empty string")); return err(new Error(`config.providers.${name}.apiKey must be a non-empty string`));
} }
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw); const apiKeyCtx = `config.providers.${name}.apiKey`;
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw, apiKeyCtx);
if (!apiKeyResult.ok) { if (!apiKeyResult.ok) {
return apiKeyResult; return apiKeyResult;
} }
return ok({ baseUrl, model, apiKey: apiKeyResult.value }); return ok({ baseUrl, apiKey: apiKeyResult.value });
}
function normalizeProviders(raw: unknown): Result<Record<string, ProviderConfig>, Error> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return err(new Error('registry config must contain a "providers" mapping'));
}
const root = raw as Record<string, unknown>;
const providers: Record<string, ProviderConfig> = {};
for (const [name, entryRaw] of Object.entries(root)) {
const next = normalizeProviderEntry(name, entryRaw);
if (!next.ok) {
return next;
}
providers[name] = next.value;
}
return ok(providers);
}
function normalizeModels(
raw: unknown,
providers: Record<string, ProviderConfig>,
): Result<Record<string, string>, Error> {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return err(new Error('registry config must contain a "models" mapping'));
}
const root = raw as Record<string, unknown>;
const models: Record<string, string> = {};
const providerKeys = new Set(Object.keys(providers));
for (const [scene, refRaw] of Object.entries(root)) {
if (scene === "") {
return err(new Error("config.models must not contain an empty scene name"));
}
if (typeof refRaw !== "string" || refRaw === "") {
return err(new Error(`config.models.${scene} must be a non-empty string (provider/model)`));
}
const ctx = `config.models.${scene}`;
const parsed = splitProviderModelRef(refRaw);
if (!parsed.ok) {
return err(new Error(`${ctx}: ${parsed.error}`));
}
if (!providerKeys.has(parsed.value.providerName)) {
return err(
new Error(
`${ctx}: unknown provider "${parsed.value.providerName}" (not listed under config.providers)`,
),
);
}
models[scene] = refRaw;
}
if (!Object.hasOwn(models, "default")) {
registryNormalizeLog(
"Z2KP9NWQ",
'registry config: models mapping has no "default" key; scenes without explicit model mappings may fail at resolveModel',
);
}
return ok(models);
} }
function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> { function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
@@ -52,15 +110,24 @@ function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
} }
const c = raw as Record<string, unknown>; const c = raw as Record<string, unknown>;
const maxDepth = c.maxDepth; const maxDepth = c.maxDepth;
const extractRaw = c.extract; const providersRaw = c.providers;
const modelsRaw = c.models;
if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) { if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) {
return err(new Error("config.maxDepth must be a non-negative integer")); return err(new Error("config.maxDepth must be a non-negative integer"));
} }
const extractResult = normalizeExtractProviderConfig(extractRaw); const providersResult = normalizeProviders(providersRaw);
if (!extractResult.ok) { if (!providersResult.ok) {
return extractResult; return providersResult;
} }
return ok({ maxDepth, extract: extractResult.value }); const modelsResult = normalizeModels(modelsRaw, providersResult.value);
if (!modelsResult.ok) {
return modelsResult;
}
return ok({
maxDepth,
providers: providersResult.value,
models: modelsResult.value,
});
} }
export function normalizeWorkflowHistoryEntry( export function normalizeWorkflowHistoryEntry(
+4 -8
View File
@@ -1,3 +1,5 @@
import type { ProviderConfig } from "../config/index.js";
export type WorkflowHistoryEntry = { export type WorkflowHistoryEntry = {
hash: string; hash: string;
timestamp: number; timestamp: number;
@@ -9,16 +11,10 @@ export type WorkflowRegistryEntry = {
history: WorkflowHistoryEntry[]; history: WorkflowHistoryEntry[];
}; };
/** LLM provider settings under `config.extract` in workflow.yaml (apiKey resolved after parse). */
export type ExtractProviderConfig = {
baseUrl: string;
model: string;
apiKey: string;
};
export type WorkflowConfig = { export type WorkflowConfig = {
maxDepth: number; maxDepth: number;
extract: ExtractProviderConfig; providers: Record<string, ProviderConfig>;
models: Record<string, string>;
}; };
export type WorkflowRegistryFile = { export type WorkflowRegistryFile = {