From aa01283ce17ae6cf611a2bfaef11b711be195d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 8 May 2026 02:08:19 +0000 Subject: [PATCH] feat: unified provider/model configuration (Phase 1) - New src/config/ folder: resolveModel(config, scene) with fallback to default - WorkflowConfig now has providers + models instead of extract - Delete ExtractProviderConfig, getExtractProvider uses resolveModel('extract') - New resolve-model tests, updated existing tests Refs #110 --- .../__tests__/extract-provider.test.ts | 24 ++-- packages/workflow/__tests__/registry.test.ts | 41 +++--- .../workflow/__tests__/resolve-model.test.ts | 100 +++++++++++++++ .../__tests__/workflow-as-agent.test.ts | 13 +- packages/workflow/src/config/index.ts | 2 + packages/workflow/src/config/resolve-model.ts | 44 +++++++ packages/workflow/src/config/types.ts | 10 ++ packages/workflow/src/extract-provider.ts | 9 +- packages/workflow/src/index.ts | 6 +- packages/workflow/src/registry/index.ts | 1 - .../src/registry/registry-normalize.ts | 117 ++++++++++++++---- packages/workflow/src/registry/types.ts | 12 +- 12 files changed, 317 insertions(+), 62 deletions(-) create mode 100644 packages/workflow/__tests__/resolve-model.test.ts create mode 100644 packages/workflow/src/config/index.ts create mode 100644 packages/workflow/src/config/resolve-model.ts create mode 100644 packages/workflow/src/config/types.ts diff --git a/packages/workflow/__tests__/extract-provider.test.ts b/packages/workflow/__tests__/extract-provider.test.ts index 775ee03..991e2a7 100644 --- a/packages/workflow/__tests__/extract-provider.test.ts +++ b/packages/workflow/__tests__/extract-provider.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { getExtractProvider } from "../src/extract-provider.js"; 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-")); try { await mkdir(root, { recursive: true }); @@ -14,10 +14,13 @@ describe("getExtractProvider", () => { join(root, "workflow.yaml"), `config: maxDepth: 3 - extract: - baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 - model: qwen-plus - apiKey: literal-key + providers: + dashscope: + baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 + apiKey: literal-key + models: + default: dashscope/qwen-turbo + extract: dashscope/qwen-plus workflows: {} `, "utf8", @@ -61,10 +64,13 @@ workflows: {} join(root, "workflow.yaml"), `config: maxDepth: 1 - extract: - baseUrl: https://example.com - model: m - apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY + providers: + p: + baseUrl: https://example.com + apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY + models: + default: p/other-model + extract: p/m workflows: {} `, "utf8", diff --git a/packages/workflow/__tests__/registry.test.ts b/packages/workflow/__tests__/registry.test.ts index 41954ed..d9b568b 100644 --- a/packages/workflow/__tests__/registry.test.ts +++ b/packages/workflow/__tests__/registry.test.ts @@ -105,10 +105,13 @@ describe("workflow registry", () => { const yaml = ` config: maxDepth: 3 - extract: - baseUrl: https://example.com/v1 - model: qwen-plus - apiKey: secret-key + providers: + dashscope: + baseUrl: https://example.com/v1 + apiKey: secret-key + models: + default: dashscope/qwen-turbo + extract: dashscope/qwen-plus workflows: solve-issue: hash: SPVR4BDMSGC1W @@ -125,9 +128,10 @@ workflows: return; } expect(r.value.config.maxDepth).toBe(3); - expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1"); - expect(r.value.config.extract.model).toBe("qwen-plus"); - expect(r.value.config.extract.apiKey).toBe("secret-key"); + expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1"); + expect(r.value.config.providers.dashscope?.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", () => { @@ -137,10 +141,13 @@ workflows: const yaml = ` config: maxDepth: 1 - extract: - baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 - model: qwen-plus - apiKey: env:WF_REGISTRY_TEST_API_KEY + providers: + dashscope: + baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 + apiKey: env:WF_REGISTRY_TEST_API_KEY + models: + default: dashscope/qwen-plus + extract: dashscope/qwen-plus workflows: {} `; const r = parseWorkflowRegistryYaml(yaml); @@ -148,7 +155,7 @@ workflows: {} if (!r.ok) { return; } - expect(r.value.config?.extract.apiKey).toBe("from-env"); + expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env"); } finally { if (prev === undefined) { delete process.env.WF_REGISTRY_TEST_API_KEY; @@ -165,10 +172,12 @@ workflows: {} const yaml = ` config: maxDepth: 1 - extract: - baseUrl: https://example.com - model: m - apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET + providers: + p: + baseUrl: https://example.com + apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET + models: + default: p/m workflows: {} `; const r = parseWorkflowRegistryYaml(yaml); diff --git a/packages/workflow/__tests__/resolve-model.test.ts b/packages/workflow/__tests__/resolve-model.test.ts new file mode 100644 index 0000000..f369e49 --- /dev/null +++ b/packages/workflow/__tests__/resolve-model.test.ts @@ -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); + }); +}); diff --git a/packages/workflow/__tests__/workflow-as-agent.test.ts b/packages/workflow/__tests__/workflow-as-agent.test.ts index 11bb7af..64bef83 100644 --- a/packages/workflow/__tests__/workflow-as-agent.test.ts +++ b/packages/workflow/__tests__/workflow-as-agent.test.ts @@ -140,10 +140,15 @@ describe("workflowAsAgent", () => { ...reg.value, config: { maxDepth: 2, - extract: { - baseUrl: "http://127.0.0.1:9", - model: "m", - apiKey: "k", + providers: { + local: { + baseUrl: "http://127.0.0.1:9", + apiKey: "k", + }, + }, + models: { + default: "local/m", + extract: "local/m", }, }, }; diff --git a/packages/workflow/src/config/index.ts b/packages/workflow/src/config/index.ts new file mode 100644 index 0000000..ecd62e1 --- /dev/null +++ b/packages/workflow/src/config/index.ts @@ -0,0 +1,2 @@ +export { resolveModel } from "./resolve-model.js"; +export type { ProviderConfig, ResolvedModel } from "./types.js"; diff --git a/packages/workflow/src/config/resolve-model.ts b/packages/workflow/src/config/resolve-model.ts new file mode 100644 index 0000000..0941352 --- /dev/null +++ b/packages/workflow/src/config/resolve-model.ts @@ -0,0 +1,44 @@ +import type { WorkflowConfig } from "../registry/index.js"; +import { err, ok, type Result } from "../util/index.js"; +import type { ResolvedModel } from "./types.js"; + +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 }); +} + +/** Resolves scene → provider endpoint + model using {@link WorkflowConfig.providers} and {@link WorkflowConfig.models}. */ +export function resolveModel(config: WorkflowConfig, scene: string): Result { + 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, + }); +} diff --git a/packages/workflow/src/config/types.ts b/packages/workflow/src/config/types.ts new file mode 100644 index 0000000..b65d1e8 --- /dev/null +++ b/packages/workflow/src/config/types.ts @@ -0,0 +1,10 @@ +export type ProviderConfig = { + baseUrl: string; + apiKey: string; +}; + +export type ResolvedModel = { + baseUrl: string; + apiKey: string; + model: string; +}; diff --git a/packages/workflow/src/extract-provider.ts b/packages/workflow/src/extract-provider.ts index 215ece7..f8e549d 100644 --- a/packages/workflow/src/extract-provider.ts +++ b/packages/workflow/src/extract-provider.ts @@ -1,3 +1,4 @@ +import { resolveModel } from "./config/index.js"; import type { WorkflowConfig } from "./registry/index.js"; import { readWorkflowRegistry } from "./registry/index.js"; import type { LlmProvider } from "./types.js"; @@ -12,7 +13,7 @@ export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): numbe 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( storageRoot: string | undefined, ): Promise> { @@ -25,7 +26,11 @@ export async function getExtractProvider( if (cfg === null) { 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({ baseUrl: ex.baseUrl, apiKey: ex.apiKey, diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 262e919..fe7642e 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -28,6 +28,11 @@ export { serializeMerkleNode, type ThreadMerklePayload, } from "./cas/index.js"; +export { + type ProviderConfig, + type ResolvedModel, + resolveModel, +} from "./config/index.js"; export { buildForkPlan, createThreadPauseGate, @@ -60,7 +65,6 @@ export { } from "./extract/index.js"; export { getExtractProvider } from "./extract-provider.js"; export { - type ExtractProviderConfig, getRegisteredWorkflow, listRegisteredWorkflowNames, parseWorkflowRegistryYaml, diff --git a/packages/workflow/src/registry/index.ts b/packages/workflow/src/registry/index.ts index 6ee97d3..e200664 100644 --- a/packages/workflow/src/registry/index.ts +++ b/packages/workflow/src/registry/index.ts @@ -11,7 +11,6 @@ export { writeWorkflowRegistry, } from "./registry.js"; export type { - ExtractProviderConfig, WorkflowConfig, WorkflowHistoryEntry, WorkflowRegistryEntry, diff --git a/packages/workflow/src/registry/registry-normalize.ts b/packages/workflow/src/registry/registry-normalize.ts index 5d70c09..12b844a 100644 --- a/packages/workflow/src/registry/registry-normalize.ts +++ b/packages/workflow/src/registry/registry-normalize.ts @@ -1,49 +1,115 @@ +import type { ProviderConfig } from "../config/index.js"; import { err, ok, type Result } from "../util/index.js"; import type { - ExtractProviderConfig, WorkflowConfig, WorkflowHistoryEntry, WorkflowRegistryEntry, WorkflowRegistryFile, } from "./types.js"; -function resolveRegistryApiKey(raw: string): Result { +function resolveRegistryApiKey(raw: string, ctx: string): Result { if (raw.startsWith("env:")) { const name = raw.slice("env:".length); 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]; 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(raw); } -function normalizeExtractProviderConfig(raw: unknown): Result { - if (raw === null || typeof raw !== "object") { - return err(new Error('registry config must contain an "extract" mapping')); +function parseModelProviderRef( + ref: string, + ctx: string, +): Result<{ providerName: string; modelName: string }, Error> { + const idx = ref.indexOf("/"); + if (idx <= 0 || idx === ref.length - 1) { + return err(new Error(`${ctx}: expected providerName/modelName, got "${ref}"`)); } - const e = raw as Record; + const providerName = ref.slice(0, idx); + const modelName = ref.slice(idx + 1); + if (providerName === "" || modelName === "") { + return err(new Error(`${ctx}: expected providerName/modelName, got "${ref}"`)); + } + return ok({ providerName, modelName }); +} + +function normalizeProviderEntry(name: string, entryRaw: unknown): Result { + if (name === "") { + return err(new Error("config.providers must not contain an empty provider name")); + } + 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; const baseUrl = e.baseUrl; - const model = e.model; const apiKeyRaw = e.apiKey; if (typeof baseUrl !== "string" || baseUrl === "") { - return err(new Error("config.extract.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")); + return err(new Error(`config.providers.${name}.baseUrl must be a non-empty string`)); } 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) { return apiKeyResult; } - return ok({ baseUrl, model, apiKey: apiKeyResult.value }); + return ok({ baseUrl, apiKey: apiKeyResult.value }); +} + +function normalizeProviders(raw: unknown): Result, 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; + const providers: Record = {}; + 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, +): Result, 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; + const models: Record = {}; + 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 = parseModelProviderRef(refRaw, ctx); + if (!parsed.ok) { + return parsed; + } + if (!providerKeys.has(parsed.value.providerName)) { + return err( + new Error( + `${ctx}: unknown provider "${parsed.value.providerName}" (not listed under config.providers)`, + ), + ); + } + models[scene] = refRaw; + } + return ok(models); } function normalizeWorkflowConfig(raw: unknown): Result { @@ -52,15 +118,24 @@ function normalizeWorkflowConfig(raw: unknown): Result { } const c = raw as Record; 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) { return err(new Error("config.maxDepth must be a non-negative integer")); } - const extractResult = normalizeExtractProviderConfig(extractRaw); - if (!extractResult.ok) { - return extractResult; + const providersResult = normalizeProviders(providersRaw); + if (!providersResult.ok) { + 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( diff --git a/packages/workflow/src/registry/types.ts b/packages/workflow/src/registry/types.ts index 861ba9d..606ed15 100644 --- a/packages/workflow/src/registry/types.ts +++ b/packages/workflow/src/registry/types.ts @@ -1,3 +1,5 @@ +import type { ProviderConfig } from "../config/index.js"; + export type WorkflowHistoryEntry = { hash: string; timestamp: number; @@ -9,16 +11,10 @@ export type WorkflowRegistryEntry = { 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 = { maxDepth: number; - extract: ExtractProviderConfig; + providers: Record; + models: Record; }; export type WorkflowRegistryFile = {