Skip to content

Commit b3f389f

Browse files
authored
feat(model-routing): add openai models, full claude catalog, passthrough routing (#35)
* feat(model-routing): add openai models, full claude catalog, passthrough routing * fix(ci): add webhookes/search/compliance to build steps, update passthrough multiplier test
1 parent 2c953f7 commit b3f389f

7 files changed

Lines changed: 455 additions & 76 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ jobs:
5151
pnpm --filter @maschina/notifications build
5252
pnpm --filter @maschina/validation build
5353
pnpm --filter @maschina/email build
54+
pnpm --filter @maschina/webhooks build
55+
pnpm --filter @maschina/search build
56+
pnpm --filter @maschina/compliance build
5457
5558
- run: pnpm typecheck
5659

@@ -135,6 +138,9 @@ jobs:
135138
pnpm --filter @maschina/notifications build
136139
pnpm --filter @maschina/validation build
137140
pnpm --filter @maschina/email build
141+
pnpm --filter @maschina/webhooks build
142+
pnpm --filter @maschina/search build
143+
pnpm --filter @maschina/compliance build
138144
139145
- name: Run migrations
140146
run: pnpm db:migrate

packages/model/src/catalog.test.ts

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,52 @@ import {
44
getAllowedModels,
55
getModel,
66
getModelMultiplier,
7+
inferProvider,
78
resolveModel,
89
validateModelAccess,
910
} from "./catalog.js";
1011

1112
describe("getModel", () => {
12-
it("returns the model def for a known ID", () => {
13-
const m = getModel("claude-haiku-4-5-20251001");
13+
it("returns the model def for a known Claude ID", () => {
14+
const m = getModel("claude-haiku-4-5");
1415
expect(m).toBeDefined();
1516
expect(m?.provider).toBe("anthropic");
1617
expect(m?.multiplier).toBe(1);
1718
});
1819

20+
it("returns the model def for a known OpenAI ID", () => {
21+
const m = getModel("gpt-5");
22+
expect(m).toBeDefined();
23+
expect(m?.provider).toBe("openai");
24+
expect(m?.multiplier).toBe(8);
25+
});
26+
1927
it("returns undefined for an unknown ID", () => {
2028
expect(getModel("gpt-99")).toBeUndefined();
2129
});
2230
});
2331

2432
describe("getModelMultiplier", () => {
25-
it("returns 1 for haiku", () => expect(getModelMultiplier("claude-haiku-4-5-20251001")).toBe(1));
33+
it("returns 1 for haiku", () => expect(getModelMultiplier("claude-haiku-4-5")).toBe(1));
2634
it("returns 3 for sonnet", () => expect(getModelMultiplier("claude-sonnet-4-6")).toBe(3));
2735
it("returns 15 for opus", () => expect(getModelMultiplier("claude-opus-4-6")).toBe(15));
2836
it("returns 0 for ollama models", () => expect(getModelMultiplier("ollama/llama3.2")).toBe(0));
29-
it("returns 1 for unknown model (safe default)", () =>
30-
expect(getModelMultiplier("unknown")).toBe(1));
37+
it("returns 1 for gpt-5-mini", () => expect(getModelMultiplier("gpt-5-mini")).toBe(1));
38+
it("returns 8 for gpt-5", () => expect(getModelMultiplier("gpt-5")).toBe(8));
39+
it("returns 20 for o3", () => expect(getModelMultiplier("o3")).toBe(20));
40+
it("returns 2 for unknown model (passthrough rate)", () =>
41+
expect(getModelMultiplier("unknown-future-model")).toBe(2));
42+
});
43+
44+
describe("inferProvider", () => {
45+
it("infers anthropic for claude- prefix", () =>
46+
expect(inferProvider("claude-sonnet-4-6")).toBe("anthropic"));
47+
it("infers openai for gpt- prefix", () => expect(inferProvider("gpt-5")).toBe("openai"));
48+
it("infers openai for o3 prefix", () => expect(inferProvider("o3-pro")).toBe("openai"));
49+
it("infers openai for o4 prefix", () => expect(inferProvider("o4-mini")).toBe("openai"));
50+
it("infers ollama for ollama/ prefix", () =>
51+
expect(inferProvider("ollama/llama3.2")).toBe("ollama"));
52+
it("returns null for unknown prefix", () => expect(inferProvider("gemini-pro")).toBeNull());
3153
});
3254

3355
describe("getAllowedModels", () => {
@@ -36,42 +58,40 @@ describe("getAllowedModels", () => {
3658
expect(allowed.every((m) => m.isLocal)).toBe(true);
3759
});
3860

39-
it("m1 tier can use haiku and ollama", () => {
61+
it("m1 tier can use haiku, gpt-5-mini, o4-mini, and ollama", () => {
4062
const ids = getAllowedModels("m1").map((m) => m.id);
41-
expect(ids).toContain("claude-haiku-4-5-20251001");
63+
expect(ids).toContain("claude-haiku-4-5");
64+
expect(ids).toContain("gpt-5-mini");
65+
expect(ids).toContain("o4-mini");
4266
expect(ids).toContain("ollama/llama3.2");
4367
expect(ids).not.toContain("claude-sonnet-4-6");
4468
expect(ids).not.toContain("claude-opus-4-6");
4569
});
4670

47-
it("m5 tier can use haiku and sonnet but not opus", () => {
71+
it("m5 tier can use sonnet and gpt-5 but not opus or o3", () => {
4872
const ids = getAllowedModels("m5").map((m) => m.id);
49-
expect(ids).toContain("claude-haiku-4-5-20251001");
5073
expect(ids).toContain("claude-sonnet-4-6");
74+
expect(ids).toContain("gpt-5");
5175
expect(ids).not.toContain("claude-opus-4-6");
76+
expect(ids).not.toContain("o3");
5277
});
5378

54-
it("m10 tier can use all models", () => {
79+
it("m10 tier can use all models including opus and o3", () => {
5580
const ids = getAllowedModels("m10").map((m) => m.id);
56-
expect(ids).toContain("claude-haiku-4-5-20251001");
57-
expect(ids).toContain("claude-sonnet-4-6");
58-
expect(ids).toContain("claude-opus-4-6");
59-
});
60-
61-
it("internal tier can use all models", () => {
62-
const ids = getAllowedModels("internal").map((m) => m.id);
6381
expect(ids).toContain("claude-opus-4-6");
82+
expect(ids).toContain("o3");
83+
expect(ids).toContain("o3-pro");
84+
expect(ids).toContain("gpt-5.4-pro");
6485
});
6586
});
6687

6788
describe("validateModelAccess", () => {
6889
it("allows access tier to use ollama", () => {
69-
const result = validateModelAccess("access", "ollama/llama3.2");
70-
expect(result.allowed).toBe(true);
90+
expect(validateModelAccess("access", "ollama/llama3.2").allowed).toBe(true);
7191
});
7292

7393
it("denies access tier from using haiku", () => {
74-
const result = validateModelAccess("access", "claude-haiku-4-5-20251001");
94+
const result = validateModelAccess("access", "claude-haiku-4-5");
7595
expect(result.allowed).toBe(false);
7696
expect(result.reason).toMatch(/m1/);
7797
});
@@ -89,30 +109,53 @@ describe("validateModelAccess", () => {
89109
});
90110

91111
it("allows m10 tier to use opus", () => {
92-
const result = validateModelAccess("m10", "claude-opus-4-6");
112+
expect(validateModelAccess("m10", "claude-opus-4-6").allowed).toBe(true);
113+
});
114+
115+
it("allows m1 tier to use gpt-5-mini", () => {
116+
expect(validateModelAccess("m1", "gpt-5-mini").allowed).toBe(true);
117+
});
118+
119+
it("allows m5 tier to use gpt-5", () => {
120+
expect(validateModelAccess("m5", "gpt-5").allowed).toBe(true);
121+
});
122+
123+
it("allows m1+ passthrough for unknown claude model", () => {
124+
const result = validateModelAccess("m1", "claude-future-model-9");
93125
expect(result.allowed).toBe(true);
126+
expect(result.passthrough).toBe(true);
127+
});
128+
129+
it("allows m1+ passthrough for unknown gpt model", () => {
130+
const result = validateModelAccess("m1", "gpt-6");
131+
expect(result.allowed).toBe(true);
132+
expect(result.passthrough).toBe(true);
133+
});
134+
135+
it("denies access tier passthrough", () => {
136+
const result = validateModelAccess("access", "gpt-6");
137+
expect(result.allowed).toBe(false);
94138
});
95139

96-
it("denies unknown model with clear error", () => {
97-
const result = validateModelAccess("enterprise", "gpt-99");
140+
it("denies unknown prefix with no inferred provider", () => {
141+
const result = validateModelAccess("enterprise", "gemini-pro");
98142
expect(result.allowed).toBe(false);
99143
expect(result.reason).toMatch(/Unknown model/);
100144
});
101145
});
102146

103147
describe("resolveModel", () => {
104148
it("returns the requested model if allowed", () => {
105-
expect(resolveModel("m5", "claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5-20251001");
149+
expect(resolveModel("m5", "claude-haiku-4-5")).toBe("claude-haiku-4-5");
106150
});
107151

108152
it("falls back to tier default if requested model is denied", () => {
109-
// m1 requesting opus → should fall back to m1 default
110153
expect(resolveModel("m1", "claude-opus-4-6")).toBe(DEFAULT_MODEL.m1);
111154
});
112155

113156
it("returns tier default when no model is requested", () => {
114157
expect(resolveModel("access")).toBe("ollama/llama3.2");
115-
expect(resolveModel("m1")).toBe("claude-haiku-4-5-20251001");
158+
expect(resolveModel("m1")).toBe("claude-haiku-4-5");
116159
expect(resolveModel("m5")).toBe("claude-sonnet-4-6");
117160
expect(resolveModel("m10")).toBe("claude-opus-4-6");
118161
});

0 commit comments

Comments
 (0)