Skip to content

Commit 957c740

Browse files
fix(chat): bump pi-ai and validate gateway model ids at startup (#244)
## Summary Fixes the production crash `Error: Unknown AI Gateway model id: openai/gpt-5.4-mini` ([JUNIOR-1Q](https://sentry.sentry.io/issues/JUNIOR-1Q)) and adds the type/runtime guardrails that would have caught it before it shipped. **Root cause.** #237 switched the default `AI_FAST_MODEL` to `openai/gpt-5.4-mini`, but `@mariozechner/pi-ai@0.59.0` was pinned and its generated `vercel-ai-gateway` registry predates that entry. Every turn where `AI_FAST_MODEL` was unset hit `resolveGatewayModel` in `packages/junior/src/chat/pi/client.ts`, found no match, and threw. `openai/gpt-5.4-mini` is the correct gateway id per [Vercel's catalog](https://vercel.com/ai-gateway/models/gpt-5.4-mini) — 0.68.1 is just the first pi-ai release that knows about it. **Fix:** - Bump `@mariozechner/pi-ai` and `@mariozechner/pi-agent-core` from `0.59.0` → `0.68.1`. Catalog now includes `openai/gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-chat`, etc. - `Agent.replaceMessages()` was removed in pi-agent-core 0.68; assign `agent.state.messages` directly instead (the setter copies the top-level array). One call site in `respond.ts`. **Guardrails so this doesn't regress silently again** (answering @dcramer's review question on whether types could have caught this): - **Compile-time check on default literals.** `config.ts` wraps `DEFAULT_MODEL_ID` / `DEFAULT_FAST_MODEL_ID` in `getModel("vercel-ai-gateway", ...)` calls. `getModel`'s second generic is `TModelId extends keyof (typeof MODELS)[TProvider]`, so a stale literal becomes a `tsc` error at the call site. Verified by temporarily swapping in a bogus id — `tsc` emitted `TS2345: Argument of type '"openai/gpt-5.4-mini-bogus"' is not assignable to parameter of type '"openai/gpt-5.4" | ... | "zai/glm-5v-turbo"'`. - **Runtime check on env overrides.** New `toGatewayModelId()` validates every `AI_MODEL` / `AI_FAST_MODEL` / `AI_VISION_MODEL` against pi-ai's registry at `readBotConfig` time. A typo in env now throws "Unknown AI Gateway model id: …" at startup, not mid-turn. New regression test in `chat-config.test.ts` pins this behavior; existing tests moved from placeholder ids (`anthropic/custom-model`) to real catalog ids (`anthropic/claude-opus-4.6`, `anthropic/claude-haiku-4.5`). We can't go further (i.e. type `BotConfig.fastModelId: GatewayModelId`) because pi-ai doesn't export `MODELS` from its package entry — the literal union is not importable as a nameable type. Defaults are compile-checked and env is runtime-checked at startup, which covers both observed regression paths. Fixes JUNIOR-1Q. ## Review & Testing Checklist for Human - [ ] Skim the pi-ai / pi-agent-core 0.60 → 0.68 changelog for any other API breaks we haven't hit yet — I verified via full typecheck + `pnpm test` but didn't walk every intermediate release. - [ ] After merge, confirm JUNIOR-1Q stops firing in production with the new release. - [ ] If any deployed env has a typo in `AI_MODEL` / `AI_FAST_MODEL` / `AI_VISION_MODEL`, the process will now fail to boot instead of failing on the first turn. Worth eyeballing current prod env before merge. ### Notes - `pnpm typecheck`, `pnpm lint`, and `pnpm test` (707 tests) all pass locally. - No `BotConfig` signature changes — `modelId`/`fastModelId`/`visionModelId` stay typed as `string` since we can't name a `GatewayModelId` union without pi-ai re-exporting `MODELS`. Link to Devin session: https://app.devin.ai/sessions/bf10e4f407dd4265a27a7d7f463eb1c3 --------- Co-authored-by: Devin AI <devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: GPT-5 <devin@cognition.ai>
1 parent 9dbb0f7 commit 957c740

6 files changed

Lines changed: 628 additions & 543 deletions

File tree

packages/junior/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
"@chat-adapter/state-memory": "4.26.0",
4040
"@chat-adapter/state-redis": "4.26.0",
4141
"@logtape/logtape": "^2.0.5",
42-
"@mariozechner/pi-agent-core": "0.59.0",
43-
"@mariozechner/pi-ai": "0.59.0",
42+
"@mariozechner/pi-agent-core": "0.68.1",
43+
"@mariozechner/pi-ai": "0.68.1",
4444
"@modelcontextprotocol/sdk": "1.29.0",
4545
"@sinclair/typebox": "^0.34.49",
4646
"@slack/web-api": "^7.15.1",

packages/junior/src/chat/config.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { getModel } from "@mariozechner/pi-ai";
12
import { toOptionalTrimmed } from "@/chat/optional-string";
3+
import { resolveGatewayModel } from "@/chat/pi/client";
24

35
const MIN_AGENT_TURN_TIMEOUT_MS = 10 * 1000;
46
const DEFAULT_AGENT_TURN_TIMEOUT_MS = 12 * 60 * 1000;
@@ -101,16 +103,33 @@ function parseLoadingMessages(rawValue: string | undefined): string[] {
101103
});
102104
}
103105

106+
// Compile-time assertion: `getModel`'s second generic is constrained to
107+
// `keyof (typeof MODELS)[TProvider]`, so a stale default becomes a tsc error.
108+
const DEFAULT_MODEL_ID = getModel("vercel-ai-gateway", "openai/gpt-5.4").id;
109+
const DEFAULT_FAST_MODEL_ID = getModel(
110+
"vercel-ai-gateway",
111+
"openai/gpt-5.4-mini",
112+
).id;
113+
114+
function validateGatewayModelId(raw: string | undefined): string | undefined {
115+
const trimmed = toOptionalTrimmed(raw);
116+
if (trimmed === undefined) return undefined;
117+
resolveGatewayModel(trimmed);
118+
return trimmed;
119+
}
120+
104121
function readBotConfig(env: NodeJS.ProcessEnv): BotConfig {
105122
const functionMaxDurationSeconds = resolveFunctionMaxDurationSeconds(env);
106123
const maxTurnTimeoutMs = resolveMaxTurnTimeoutMs(functionMaxDurationSeconds);
107124

108125
return {
109126
userName: env.JUNIOR_BOT_NAME ?? "junior",
110-
modelId: env.AI_MODEL ?? "openai/gpt-5.4",
111-
fastModelId: env.AI_FAST_MODEL ?? env.AI_MODEL ?? "openai/gpt-5.4-mini",
127+
modelId: validateGatewayModelId(env.AI_MODEL) ?? DEFAULT_MODEL_ID,
128+
fastModelId:
129+
validateGatewayModelId(env.AI_FAST_MODEL ?? env.AI_MODEL) ??
130+
DEFAULT_FAST_MODEL_ID,
112131
loadingMessages: parseLoadingMessages(env.JUNIOR_LOADING_MESSAGES),
113-
visionModelId: toOptionalTrimmed(env.AI_VISION_MODEL),
132+
visionModelId: validateGatewayModelId(env.AI_VISION_MODEL),
114133
turnTimeoutMs: parseAgentTurnTimeoutMs(
115134
env.AGENT_TURN_TIMEOUT_MS,
116135
maxTurnTimeoutMs,

packages/junior/src/chat/pi/client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,15 @@ function parseJsonCandidate(text: string): unknown {
129129
}
130130
}
131131

132+
/**
133+
* Look up a gateway model by id. Throws `Unknown AI Gateway model id: …` if
134+
* the id is not in pi-ai's registry — callers at the config boundary can use
135+
* this to fail fast at startup instead of mid-turn.
136+
*/
132137
export function resolveGatewayModel(modelId: string): Model<any> {
133-
const models = getModels(GATEWAY_PROVIDER);
134-
const matched = models.find((model: Model<any>) => model.id === modelId);
138+
const matched = getModels(GATEWAY_PROVIDER).find(
139+
(model: Model<any>) => model.id === modelId,
140+
);
135141
if (!matched) {
136142
throw new Error(`Unknown AI Gateway model id: ${modelId}`);
137143
}

packages/junior/src/chat/respond.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,7 @@ export async function generateAssistantReply(
885885

886886
try {
887887
if (resumedFromCheckpoint) {
888-
agent.replaceMessages(existingCheckpoint!.piMessages);
888+
agent.state.messages = existingCheckpoint!.piMessages;
889889
}
890890
beforeMessageCount = agent.state.messages.length;
891891

packages/junior/tests/unit/config/chat-config.test.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,20 @@ describe("chat config", () => {
1414
});
1515

1616
it("uses AI_MODEL for fastModelId when AI_FAST_MODEL is unset", async () => {
17-
process.env.AI_MODEL = "anthropic/custom-model";
17+
process.env.AI_MODEL = "anthropic/claude-opus-4.6";
1818
delete process.env.AI_FAST_MODEL;
1919

2020
const { botConfig } = await loadConfig();
21-
expect(botConfig.modelId).toBe("anthropic/custom-model");
22-
expect(botConfig.fastModelId).toBe("anthropic/custom-model");
21+
expect(botConfig.modelId).toBe("anthropic/claude-opus-4.6");
22+
expect(botConfig.fastModelId).toBe("anthropic/claude-opus-4.6");
2323
});
2424

2525
it("prefers AI_FAST_MODEL over AI_MODEL for fastModelId", async () => {
26-
process.env.AI_MODEL = "anthropic/custom-model";
27-
process.env.AI_FAST_MODEL = "anthropic/custom-fast-model";
26+
process.env.AI_MODEL = "anthropic/claude-opus-4.6";
27+
process.env.AI_FAST_MODEL = "anthropic/claude-haiku-4.5";
2828

2929
const { botConfig } = await loadConfig();
30-
expect(botConfig.fastModelId).toBe("anthropic/custom-fast-model");
30+
expect(botConfig.fastModelId).toBe("anthropic/claude-haiku-4.5");
3131
});
3232

3333
it("uses the default fast model when AI_MODEL and AI_FAST_MODEL are unset", async () => {
@@ -46,31 +46,37 @@ describe("chat config", () => {
4646
});
4747

4848
it("ignores AI_LIGHT_MODEL and keeps using AI_FAST_MODEL", async () => {
49-
process.env.AI_MODEL = "anthropic/custom-model";
50-
process.env.AI_FAST_MODEL = "anthropic/custom-fast-model";
49+
process.env.AI_MODEL = "anthropic/claude-opus-4.6";
50+
process.env.AI_FAST_MODEL = "anthropic/claude-haiku-4.5";
5151
process.env.AI_LIGHT_MODEL = "openai/gpt-5.4-mini";
5252

5353
const { botConfig } = await loadConfig();
54-
expect(botConfig.fastModelId).toBe("anthropic/custom-fast-model");
54+
expect(botConfig.fastModelId).toBe("anthropic/claude-haiku-4.5");
5555
});
5656

5757
it("leaves visionModelId unset when AI_VISION_MODEL is absent", async () => {
58-
process.env.AI_MODEL = "anthropic/custom-model";
58+
process.env.AI_MODEL = "anthropic/claude-opus-4.6";
5959
delete process.env.AI_VISION_MODEL;
6060

6161
const { botConfig } = await loadConfig();
6262
expect(botConfig.visionModelId).toBeUndefined();
6363
});
6464

6565
it("uses AI_VISION_MODEL without falling back to AI_MODEL", async () => {
66-
process.env.AI_MODEL = "anthropic/custom-model";
66+
process.env.AI_MODEL = "anthropic/claude-opus-4.6";
6767
process.env.AI_VISION_MODEL = "openai/gpt-5.4";
6868

6969
const { botConfig } = await loadConfig();
70-
expect(botConfig.modelId).toBe("anthropic/custom-model");
70+
expect(botConfig.modelId).toBe("anthropic/claude-opus-4.6");
7171
expect(botConfig.visionModelId).toBe("openai/gpt-5.4");
7272
});
7373

74+
it("throws at config load when AI_MODEL is not a registered gateway model id", async () => {
75+
process.env.AI_MODEL = "openai/gpt-definitely-not-real";
76+
77+
await expect(loadConfig()).rejects.toThrow(/Unknown AI Gateway model id/);
78+
});
79+
7480
it("uses the default assistant loading messages when unset", async () => {
7581
delete process.env.JUNIOR_LOADING_MESSAGES;
7682
const { botConfig } = await loadConfig();

0 commit comments

Comments
 (0)