diff --git a/apps/api/package.json b/apps/api/package.json index a9b7d46..a9c3f60 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,5 +2,16 @@ "name": "@aiquant/api", "version": "0.0.0", "private": true, - "type": "module" + "type": "module", + "scripts": { + "test": "vitest run --" + }, + "dependencies": { + "@aiquant/shared": "workspace:*", + "zod": "^3.24.1" + }, + "devDependencies": { + "typescript": "^5.9.2", + "vitest": "^2.1.9" + } } diff --git a/apps/api/src/config/config.test.ts b/apps/api/src/config/config.test.ts new file mode 100644 index 0000000..90a00f0 --- /dev/null +++ b/apps/api/src/config/config.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { createConfigFromEnv, createModelProfileRegistry } from "./model-profile-registry"; + +describe("model profile registry", () => { + const env = { + MODEL_PROFILES_JSON: JSON.stringify([ + { + name: "signal.fast.v1", + provider: "openai-compatible", + apiBase: "https://example.com/v1", + apiKeyRef: "OPENAI_API_KEY", + model: "gpt-4.1-mini", + temperature: 0.2, + timeoutMs: 10000, + capabilities: { + structuredOutputs: true, + jsonMode: true, + }, + tags: ["fast"], + }, + { + name: "optimizer.reasoning.v1", + provider: "openai-compatible", + apiBase: "https://example.com/v1", + apiKeyRef: "OPENAI_API_KEY", + model: "o4-mini", + temperature: 0.6, + timeoutMs: 20000, + capabilities: { + structuredOutputs: true, + jsonMode: true, + }, + tags: ["reasoning"], + }, + ]), + AGENT_MODEL_BINDINGS_JSON: JSON.stringify({ + signal: "signal.fast.v1", + risk: "signal.fast.v1", + evaluator: "optimizer.reasoning.v1", + optimizer: "optimizer.reasoning.v1", + }), + }; + + it("loads model profiles from environment-backed config", () => { + const config = createConfigFromEnv(env); + + expect(config.modelProfiles).toHaveLength(2); + expect(config.agentBindings.optimizer).toBe("optimizer.reasoning.v1"); + }); + + it("resolves agent bindings to concrete profiles", () => { + const config = createConfigFromEnv(env); + const registry = createModelProfileRegistry(config); + + expect(registry.get("signal.fast.v1").model).toBe("gpt-4.1-mini"); + expect(registry.resolveAgentBinding("optimizer").model).toBe("o4-mini"); + }); + + it("rejects bindings to unknown profiles", () => { + expect(() => + createModelProfileRegistry({ + modelProfiles: createConfigFromEnv(env).modelProfiles, + agentBindings: { + signal: "signal.fast.v1", + risk: "signal.fast.v1", + evaluator: "signal.fast.v1", + optimizer: "missing-profile", + }, + }), + ).toThrow(/Unknown model profile/); + }); +}); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..d05a0cc --- /dev/null +++ b/apps/api/src/config/env.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +const jsonStringSchema = z.string().min(1); + +export const agentBindingSchema = z.object({ + signal: z.string().min(1), + risk: z.string().min(1), + evaluator: z.string().min(1), + optimizer: z.string().min(1), +}); + +export const configEnvSchema = z.object({ + MODEL_PROFILES_JSON: jsonStringSchema, + AGENT_MODEL_BINDINGS_JSON: jsonStringSchema, +}); + +export type AgentBindings = z.infer; +export type ConfigEnv = z.infer; diff --git a/apps/api/src/config/model-profile-registry.ts b/apps/api/src/config/model-profile-registry.ts new file mode 100644 index 0000000..44bdcef --- /dev/null +++ b/apps/api/src/config/model-profile-registry.ts @@ -0,0 +1,46 @@ +import { modelProfileSchema, type ModelProfile } from "@aiquant/shared"; +import { agentBindingSchema, configEnvSchema, type AgentBindings } from "./env"; + +export interface AppConfig { + modelProfiles: ModelProfile[]; + agentBindings: AgentBindings; +} + +export interface ModelProfileRegistry { + get(name: string): ModelProfile; + resolveAgentBinding(agent: keyof AgentBindings): ModelProfile; +} + +export function createConfigFromEnv(env: Record): AppConfig { + const parsedEnv = configEnvSchema.parse(env); + + return { + modelProfiles: modelProfileSchema.array().parse(JSON.parse(parsedEnv.MODEL_PROFILES_JSON)), + agentBindings: agentBindingSchema.parse(JSON.parse(parsedEnv.AGENT_MODEL_BINDINGS_JSON)), + }; +} + +export function createModelProfileRegistry(config: AppConfig): ModelProfileRegistry { + const profilesByName = new Map(config.modelProfiles.map((profile) => [profile.name, profile])); + + const requireProfile = (profileName: string): ModelProfile => { + const profile = profilesByName.get(profileName); + + if (!profile) { + throw new Error(`Unknown model profile: ${profileName}`); + } + + return profile; + }; + + Object.values(config.agentBindings).forEach(requireProfile); + + return { + get(name) { + return requireProfile(name); + }, + resolveAgentBinding(agent) { + return requireProfile(config.agentBindings[agent]); + }, + }; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 2bdc995..6fb1e6f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "test": "vitest run --" },