diff --git a/packages/shared/package.json b/packages/shared/package.json index 0e29c6c..ce1e22a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9e03490..f440e17 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ +export * from "./model-routing"; export * from "./types"; export * from "./utils"; diff --git a/packages/shared/src/model-routing/constants.ts b/packages/shared/src/model-routing/constants.ts new file mode 100644 index 0000000..835386f --- /dev/null +++ b/packages/shared/src/model-routing/constants.ts @@ -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> = { + "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> = { + "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", +}; diff --git a/packages/shared/src/model-routing/index.ts b/packages/shared/src/model-routing/index.ts new file mode 100644 index 0000000..c6fe5ed --- /dev/null +++ b/packages/shared/src/model-routing/index.ts @@ -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"; diff --git a/packages/shared/src/model-routing/resolve-model.test.ts b/packages/shared/src/model-routing/resolve-model.test.ts new file mode 100644 index 0000000..354b49c --- /dev/null +++ b/packages/shared/src/model-routing/resolve-model.test.ts @@ -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 & Pick, +): 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"); + }); +}); diff --git a/packages/shared/src/model-routing/resolve-model.ts b/packages/shared/src/model-routing/resolve-model.ts new file mode 100644 index 0000000..6db25b0 --- /dev/null +++ b/packages/shared/src/model-routing/resolve-model.ts @@ -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; + getApiKey: (envVarName: string) => string | undefined; + defaultRoutes?: Readonly>; +}; + +function normalizeAlias( + rawAlias: string, + legacyAliases?: Record, +): 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, + }; +} diff --git a/packages/shared/src/model-routing/schemas.ts b/packages/shared/src/model-routing/schemas.ts new file mode 100644 index 0000000..85a17cb --- /dev/null +++ b/packages/shared/src/model-routing/schemas.ts @@ -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; + +export const modelTierSchema = z.enum(["premium", "fast", "local", "auto"]); + +export type ModelTier = z.infer; + +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; + +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; + +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; + +export const convoyModelPolicySchema = z.object({ + roles: z.record(z.string()).optional(), + escalate_to: z.string().min(1).optional(), +}); + +export type ConvoyModelPolicy = z.infer;