feat(shared): add model routing schemas and resolveModel()
Implements Epic 1 task 1 per docs/adr-model-routing.md: zod schemas for providers and role routes, pipeline default aliases, legacy Cursor slug map, and a pure resolveModel() with brief → convoy → workspace → default order. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e256380671
commit
cda4958b17
7 changed files with 517 additions and 0 deletions
|
|
@ -7,6 +7,7 @@
|
|||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./model-routing": "./src/model-routing/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./utils": "./src/utils/index.ts"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./model-routing";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
|
|
|
|||
28
packages/shared/src/model-routing/constants.ts
Normal file
28
packages/shared/src/model-routing/constants.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Shipped pipeline defaults — alias *names*, not provider UUIDs. Operators remap
|
||||
* aliases in Settings; these are fallbacks when no workspace route exists.
|
||||
*/
|
||||
export const PIPELINE_DEFAULT_ROLE_ROUTES: Readonly<Record<string, string>> = {
|
||||
"role-conductor": "claude-planning",
|
||||
"role-architect": "claude-planning",
|
||||
"role-ia-architect": "claude-planning",
|
||||
"role-ux-reviewer": "qwen-coder",
|
||||
"role-implementer": "qwen-coder",
|
||||
"role-reviewer": "qwen-coder",
|
||||
"role-design-system-auditor": "qwen-coder",
|
||||
"role-a11y-auditor": "qwen-coder",
|
||||
"role-doc-writer": "qwen-coder",
|
||||
"gate-copilot": "claude-planning",
|
||||
"chat-default": "qwen-coder",
|
||||
};
|
||||
|
||||
/** Cursor-era model slugs → registry alias names (workspace may override). */
|
||||
export const LEGACY_CURSOR_MODEL_ALIASES: Readonly<Record<string, string>> = {
|
||||
"claude-4.6-opus-high-thinking": "claude-planning",
|
||||
"claude-4.6-sonnet-medium-thinking": "claude-planning",
|
||||
"claude-opus-4-8-thinking-high": "claude-planning",
|
||||
"claude-sonnet-5-thinking-medium": "claude-planning",
|
||||
"composer-2.5-fast": "qwen-coder",
|
||||
"composer-2.5": "qwen-coder",
|
||||
auto: "qwen-coder",
|
||||
};
|
||||
28
packages/shared/src/model-routing/index.ts
Normal file
28
packages/shared/src/model-routing/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export {
|
||||
LEGACY_CURSOR_MODEL_ALIASES,
|
||||
PIPELINE_DEFAULT_ROLE_ROUTES,
|
||||
} from "./constants.js";
|
||||
export {
|
||||
convoyModelPolicySchema,
|
||||
modelProviderSchema,
|
||||
modelTierSchema,
|
||||
providerTypeSchema,
|
||||
resolvedModelSchema,
|
||||
roleModelRouteSchema,
|
||||
type ConvoyModelPolicy,
|
||||
type ModelProvider,
|
||||
type ModelTier,
|
||||
type ProviderType,
|
||||
type ResolvedModel,
|
||||
type RoleModelRoute,
|
||||
} from "./schemas.js";
|
||||
export {
|
||||
resolveModel,
|
||||
resolveModelErrorCodeSchema,
|
||||
type ResolveModelError,
|
||||
type ResolveModelErrorCode,
|
||||
type ResolveModelFailure,
|
||||
type ResolveModelInput,
|
||||
type ResolveModelResult,
|
||||
type ResolveModelSuccess,
|
||||
} from "./resolve-model.js";
|
||||
172
packages/shared/src/model-routing/resolve-model.test.ts
Normal file
172
packages/shared/src/model-routing/resolve-model.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LEGACY_CURSOR_MODEL_ALIASES } from "./constants.js";
|
||||
import type { ModelProvider, RoleModelRoute } from "./schemas.js";
|
||||
import { resolveModel } from "./resolve-model.js";
|
||||
|
||||
const WORKSPACE_ID = "379ecb2f-6db9-4c53-93ad-72c594eb44a5";
|
||||
|
||||
function provider(
|
||||
overrides: Partial<ModelProvider> & Pick<ModelProvider, "alias" | "providerType" | "modelId">,
|
||||
): ModelProvider {
|
||||
return {
|
||||
id: overrides.id ?? "11111111-1111-1111-1111-111111111111",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
displayName: overrides.displayName ?? overrides.alias,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const claude = provider({
|
||||
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
alias: "claude-planning",
|
||||
providerType: "anthropic",
|
||||
modelId: "claude-sonnet-4-20250514",
|
||||
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
||||
metadata: { tier: "premium" },
|
||||
});
|
||||
|
||||
const qwen = provider({
|
||||
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
alias: "qwen-coder",
|
||||
providerType: "openai_compatible",
|
||||
modelId: "qwen2.5-coder:32b",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
apiKeyEnvVar: "OLLAMA_API_KEY",
|
||||
metadata: { tier: "local" },
|
||||
});
|
||||
|
||||
const routes: RoleModelRoute[] = [
|
||||
{
|
||||
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
roleId: "role-implementer",
|
||||
providerAliasId: qwen.id,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const getApiKey = (name: string) =>
|
||||
({ ANTHROPIC_API_KEY: "sk-ant-test", OLLAMA_API_KEY: "ollama" })[name];
|
||||
|
||||
describe("resolveModel", () => {
|
||||
it("prefers brief recommended_model over convoy, workspace, and default", () => {
|
||||
const result = resolveModel({
|
||||
roleId: "role-implementer",
|
||||
providers: [claude, qwen],
|
||||
routes,
|
||||
convoyPolicy: { roles: { "role-implementer": "qwen-coder" } },
|
||||
briefRecommendedModel: "claude-planning",
|
||||
getApiKey,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.source).toBe("brief");
|
||||
expect(result.model.alias).toBe("claude-planning");
|
||||
});
|
||||
|
||||
it("prefers convoy policy over workspace route and pipeline default", () => {
|
||||
const result = resolveModel({
|
||||
roleId: "role-implementer",
|
||||
providers: [claude, qwen],
|
||||
routes,
|
||||
convoyPolicy: { roles: { "role-implementer": "claude-planning" } },
|
||||
getApiKey,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.source).toBe("convoy");
|
||||
expect(result.model.alias).toBe("claude-planning");
|
||||
});
|
||||
|
||||
it("uses workspace route when convoy and brief are absent", () => {
|
||||
const result = resolveModel({
|
||||
roleId: "role-implementer",
|
||||
providers: [claude, qwen],
|
||||
routes,
|
||||
getApiKey,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.source).toBe("workspace");
|
||||
expect(result.model.alias).toBe("qwen-coder");
|
||||
expect(result.model.tier).toBe("local");
|
||||
});
|
||||
|
||||
it("falls back to pipeline default role routes", () => {
|
||||
const result = resolveModel({
|
||||
roleId: "role-architect",
|
||||
providers: [claude, qwen],
|
||||
routes: [],
|
||||
getApiKey,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.source).toBe("default");
|
||||
expect(result.model.alias).toBe("claude-planning");
|
||||
});
|
||||
|
||||
it("returns ALIAS_NOT_RESOLVED when no layer matches", () => {
|
||||
const result = resolveModel({
|
||||
roleId: "role-custom-unknown",
|
||||
providers: [claude, qwen],
|
||||
routes: [],
|
||||
defaultRoutes: {},
|
||||
getApiKey,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("ALIAS_NOT_RESOLVED");
|
||||
});
|
||||
|
||||
it("returns PROVIDER_DISABLED when alias maps to a disabled provider", () => {
|
||||
const result = resolveModel({
|
||||
roleId: "role-implementer",
|
||||
providers: [{ ...qwen, enabled: false }],
|
||||
routes: [],
|
||||
briefRecommendedModel: "qwen-coder",
|
||||
getApiKey,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("PROVIDER_DISABLED");
|
||||
expect(result.error.alias).toBe("qwen-coder");
|
||||
});
|
||||
|
||||
it("remaps legacy Cursor model slugs before catalog lookup", () => {
|
||||
const legacySlug = "composer-2.5-fast";
|
||||
expect(LEGACY_CURSOR_MODEL_ALIASES[legacySlug]).toBe("qwen-coder");
|
||||
|
||||
const result = resolveModel({
|
||||
roleId: "role-implementer",
|
||||
providers: [qwen],
|
||||
routes: [],
|
||||
briefRecommendedModel: legacySlug,
|
||||
getApiKey,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.resolvedAlias).toBe("qwen-coder");
|
||||
});
|
||||
|
||||
it("returns API_KEY_MISSING when required env var is absent", () => {
|
||||
const result = resolveModel({
|
||||
roleId: "role-architect",
|
||||
providers: [claude],
|
||||
routes: [],
|
||||
getApiKey: () => undefined,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("API_KEY_MISSING");
|
||||
});
|
||||
});
|
||||
213
packages/shared/src/model-routing/resolve-model.ts
Normal file
213
packages/shared/src/model-routing/resolve-model.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
LEGACY_CURSOR_MODEL_ALIASES,
|
||||
PIPELINE_DEFAULT_ROLE_ROUTES,
|
||||
} from "./constants.js";
|
||||
import type {
|
||||
ConvoyModelPolicy,
|
||||
ModelProvider,
|
||||
ModelTier,
|
||||
ResolvedModel,
|
||||
RoleModelRoute,
|
||||
} from "./schemas.js";
|
||||
import { resolvedModelSchema } from "./schemas.js";
|
||||
|
||||
export const resolveModelErrorCodeSchema = [
|
||||
"ALIAS_NOT_RESOLVED",
|
||||
"PROVIDER_NOT_FOUND",
|
||||
"PROVIDER_DISABLED",
|
||||
"BASE_URL_REQUIRED",
|
||||
"API_KEY_MISSING",
|
||||
] as const;
|
||||
|
||||
export type ResolveModelErrorCode = (typeof resolveModelErrorCodeSchema)[number];
|
||||
|
||||
export type ResolveModelError = {
|
||||
code: ResolveModelErrorCode;
|
||||
message: string;
|
||||
alias?: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export type ResolveModelSuccess = {
|
||||
ok: true;
|
||||
model: ResolvedModel;
|
||||
source: "brief" | "convoy" | "workspace" | "default";
|
||||
resolvedAlias: string;
|
||||
};
|
||||
|
||||
export type ResolveModelFailure = {
|
||||
ok: false;
|
||||
error: ResolveModelError;
|
||||
};
|
||||
|
||||
export type ResolveModelResult = ResolveModelSuccess | ResolveModelFailure;
|
||||
|
||||
export type ResolveModelInput = {
|
||||
roleId: string;
|
||||
providers: ModelProvider[];
|
||||
routes: RoleModelRoute[];
|
||||
convoyPolicy?: ConvoyModelPolicy;
|
||||
briefRecommendedModel?: string;
|
||||
/** Workspace metadata `legacy_model_aliases` — checked after global legacy map. */
|
||||
legacyAliases?: Record<string, string>;
|
||||
getApiKey: (envVarName: string) => string | undefined;
|
||||
defaultRoutes?: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
function normalizeAlias(
|
||||
rawAlias: string,
|
||||
legacyAliases?: Record<string, string>,
|
||||
): string {
|
||||
const trimmed = rawAlias.trim();
|
||||
return (
|
||||
legacyAliases?.[trimmed] ??
|
||||
LEGACY_CURSOR_MODEL_ALIASES[trimmed] ??
|
||||
trimmed
|
||||
);
|
||||
}
|
||||
|
||||
function readTier(metadata: ModelProvider["metadata"]): ModelTier | undefined {
|
||||
if (!metadata || typeof metadata !== "object") return undefined;
|
||||
const tier = (metadata as { tier?: unknown }).tier;
|
||||
if (
|
||||
tier === "premium" ||
|
||||
tier === "fast" ||
|
||||
tier === "local" ||
|
||||
tier === "auto"
|
||||
) {
|
||||
return tier;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickAlias(
|
||||
input: ResolveModelInput,
|
||||
): { alias: string; source: ResolveModelSuccess["source"] } | null {
|
||||
const brief = input.briefRecommendedModel?.trim();
|
||||
if (brief) {
|
||||
return { alias: brief, source: "brief" };
|
||||
}
|
||||
|
||||
const convoyAlias = input.convoyPolicy?.roles?.[input.roleId]?.trim();
|
||||
if (convoyAlias) {
|
||||
return { alias: convoyAlias, source: "convoy" };
|
||||
}
|
||||
|
||||
const route = input.routes.find(
|
||||
(row) => row.roleId === input.roleId && row.enabled,
|
||||
);
|
||||
if (route) {
|
||||
const provider = input.providers.find((p) => p.id === route.providerAliasId);
|
||||
if (provider?.alias) {
|
||||
return { alias: provider.alias, source: "workspace" };
|
||||
}
|
||||
}
|
||||
|
||||
const defaults = input.defaultRoutes ?? PIPELINE_DEFAULT_ROLE_ROUTES;
|
||||
const fallback = defaults[input.roleId]?.trim();
|
||||
if (fallback) {
|
||||
return { alias: fallback, source: "default" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fail(
|
||||
code: ResolveModelErrorCode,
|
||||
message: string,
|
||||
roleId: string,
|
||||
alias?: string,
|
||||
): ResolveModelFailure {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code, message, roleId, alias },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure resolver — callers load workspace provider/route rows and pass plain
|
||||
* objects. Resolution order: brief → convoy → workspace route → pipeline default.
|
||||
*/
|
||||
export function resolveModel(input: ResolveModelInput): ResolveModelResult {
|
||||
const picked = pickAlias(input);
|
||||
if (!picked) {
|
||||
return fail(
|
||||
"ALIAS_NOT_RESOLVED",
|
||||
`No model alias resolved for role "${input.roleId}"`,
|
||||
input.roleId,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedAlias = normalizeAlias(picked.alias, input.legacyAliases);
|
||||
const provider = input.providers.find((p) => p.alias === resolvedAlias);
|
||||
if (!provider) {
|
||||
return fail(
|
||||
"PROVIDER_NOT_FOUND",
|
||||
`Provider alias "${resolvedAlias}" is not registered in this workspace`,
|
||||
input.roleId,
|
||||
resolvedAlias,
|
||||
);
|
||||
}
|
||||
|
||||
if (!provider.enabled) {
|
||||
return fail(
|
||||
"PROVIDER_DISABLED",
|
||||
`Provider alias "${resolvedAlias}" is disabled`,
|
||||
input.roleId,
|
||||
resolvedAlias,
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.providerType === "openai_compatible" && !provider.baseUrl) {
|
||||
return fail(
|
||||
"BASE_URL_REQUIRED",
|
||||
`Provider "${resolvedAlias}" requires baseUrl for openai_compatible`,
|
||||
input.roleId,
|
||||
resolvedAlias,
|
||||
);
|
||||
}
|
||||
|
||||
let apiKey = "";
|
||||
if (provider.apiKeyEnvVar) {
|
||||
const value = input.getApiKey(provider.apiKeyEnvVar);
|
||||
if (value === undefined || value.length === 0) {
|
||||
return fail(
|
||||
"API_KEY_MISSING",
|
||||
`Environment variable "${provider.apiKeyEnvVar}" is not set for provider "${resolvedAlias}"`,
|
||||
input.roleId,
|
||||
resolvedAlias,
|
||||
);
|
||||
}
|
||||
apiKey = value;
|
||||
}
|
||||
|
||||
const model: ResolvedModel = {
|
||||
alias: resolvedAlias,
|
||||
providerType: provider.providerType,
|
||||
modelId: provider.modelId,
|
||||
apiKey,
|
||||
...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
|
||||
...(provider.extraHeaders ? { extraHeaders: provider.extraHeaders } : {}),
|
||||
...(provider.defaultParams ? { defaultParams: provider.defaultParams } : {}),
|
||||
...(readTier(provider.metadata)
|
||||
? { tier: readTier(provider.metadata) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const parsed = resolvedModelSchema.safeParse(model);
|
||||
if (!parsed.success) {
|
||||
return fail(
|
||||
"PROVIDER_NOT_FOUND",
|
||||
parsed.error.issues.map((i) => i.message).join("; "),
|
||||
input.roleId,
|
||||
resolvedAlias,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
model: parsed.data,
|
||||
source: picked.source,
|
||||
resolvedAlias,
|
||||
};
|
||||
}
|
||||
74
packages/shared/src/model-routing/schemas.ts
Normal file
74
packages/shared/src/model-routing/schemas.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const providerTypeSchema = z.enum([
|
||||
"anthropic",
|
||||
"openai",
|
||||
"google",
|
||||
"openai_compatible",
|
||||
"azure_openai",
|
||||
]);
|
||||
|
||||
export type ProviderType = z.infer<typeof providerTypeSchema>;
|
||||
|
||||
export const modelTierSchema = z.enum(["premium", "fast", "local", "auto"]);
|
||||
|
||||
export type ModelTier = z.infer<typeof modelTierSchema>;
|
||||
|
||||
export const modelProviderSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
alias: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
providerType: providerTypeSchema,
|
||||
modelId: z.string().min(1),
|
||||
baseUrl: z.string().url().nullable().optional(),
|
||||
apiKeyEnvVar: z.string().min(1).nullable().optional(),
|
||||
extraHeaders: z.record(z.string()).nullable().optional(),
|
||||
defaultParams: z
|
||||
.object({
|
||||
temperature: z.number().optional(),
|
||||
maxTokens: z.number().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
metadata: z.record(z.unknown()).nullable().optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type ModelProvider = z.infer<typeof modelProviderSchema>;
|
||||
|
||||
export const roleModelRouteSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
roleId: z.string().min(1),
|
||||
providerAliasId: z.string().uuid(),
|
||||
enabled: z.boolean().default(true),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export type RoleModelRoute = z.infer<typeof roleModelRouteSchema>;
|
||||
|
||||
export const resolvedModelSchema = z.object({
|
||||
alias: z.string().min(1),
|
||||
providerType: providerTypeSchema,
|
||||
modelId: z.string().min(1),
|
||||
baseUrl: z.string().url().optional(),
|
||||
apiKey: z.string(),
|
||||
extraHeaders: z.record(z.string()).optional(),
|
||||
defaultParams: z
|
||||
.object({
|
||||
temperature: z.number().optional(),
|
||||
maxTokens: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
tier: modelTierSchema.optional(),
|
||||
});
|
||||
|
||||
export type ResolvedModel = z.infer<typeof resolvedModelSchema>;
|
||||
|
||||
export const convoyModelPolicySchema = z.object({
|
||||
roles: z.record(z.string()).optional(),
|
||||
escalate_to: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type ConvoyModelPolicy = z.infer<typeof convoyModelPolicySchema>;
|
||||
Loading…
Reference in a new issue