Skip to content

Commit 1252e4a

Browse files
committed
Negotiate the output-token parameter instead of assuming it (#60)
OpenAI's newer models (GPT-5 family, o-series) reject `max_tokens` with a 400 and ask for `max_completion_tokens`. BYOK users pointing Nives at them got that error instead of an answer; every other endpoint we support — including the one Cloud uses — still wants `max_tokens`, so we cannot simply switch. Sniffing model names would be wrong twice over: the same model takes different parameters depending on who serves it, and the list would need editing on every OpenAI launch. Instead, send `max_tokens` and, only if the endpoint objects, send it again the other way and remember the answer for the process. Endpoints that work today are byte-identical; affected ones pay one 400 (zero tokens billed) per model per restart. Retrying is safe for the streaming call because the 400 arrives on the initial request, before any chunk reaches the caller. Verified over real HTTP against a fake endpoint that reproduces the reported 400. Also: on these models the cap covers hidden reasoning tokens, so a reasoning-heavy turn can exhaust it before writing a visible word. The truncation hint now says that when it applies, rather than blaming prompt size.
1 parent 5e236b4 commit 1252e4a

7 files changed

Lines changed: 281 additions & 25 deletions

File tree

nives/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 2.4.24
4+
5+
- **Bring-your-own-key now works with OpenAI's newest models.** Pointing Nives at GPT-5 or the o-series with your own OpenAI key failed before the model ever got a chance to answer: those models renamed the setting that limits how long a reply may be, and Nives was still sending the previous name. Nives now asks the endpoint which name it expects and remembers the answer, so the setting arrives correctly everywhere — OpenAI's newest models, OpenAI's older ones, and every other OpenAI-compatible endpoint Nives supports, local models included. There is nothing to configure, and setups that already worked are untouched. Thanks to @Kristofer-KNE for the report (#60).
6+
- **A clearer explanation when a thinking model runs out of room.** Models that reason before they answer spend the same reply-length budget on that reasoning, so a long deliberation can leave nothing over for the answer itself. When that happens, Nives now tells you exactly that and suggests a non-reasoning model or a lower reasoning effort, rather than pointing you at the size of your prompt.
7+
38
## 2.4.23
49

510
- **There is now exactly one place to set the assistant's personality.** Until now there were two Custom Prompt fields — one in the add-on's Configuration tab and one tucked behind the Nives integration's Configure button — and whichever was set in the second silently won. Fill in one while something sat in the other and your prompt did nothing, with no hint as to why. The integration field is gone; the add-on's Configuration tab — where your keys, logs and updates already live — is now the single home for the personality, and what you write there is always what runs. If your prompt lived in the integration field, move it over once (Settings → Apps → Nives → Configuration, then restart the add-on when asked) — and asking Nives to change its name will point you to the right place too.

nives/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "Nives"
2-
version: "2.4.23"
2+
version: "2.4.24"
33
slug: "nives"
44
description: "AI assistant with cognitive memory for Home Assistant"
55
url: "https://github.com/hoornet/nives"

server/src/home-mind-server/src/llm/openai-client.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ vi.mock("./tool-handler.js", () => ({
4343
}));
4444

4545
import { OpenAIChatEngine } from "./openai-client.js";
46+
import { resetTokenCapCache } from "./token-cap.js";
4647
import { handleToolCall, extractAndStoreFacts } from "./tool-handler.js";
4748

4849
describe("OpenAIChatEngine", () => {
@@ -54,6 +55,7 @@ describe("OpenAIChatEngine", () => {
5455
let config: Config;
5556

5657
beforeEach(() => {
58+
resetTokenCapCache();
5759
mockCreate.mockReset();
5860
vi.mocked(handleToolCall).mockReset();
5961
vi.mocked(extractAndStoreFacts).mockReset();
@@ -404,6 +406,29 @@ describe("OpenAIChatEngine", () => {
404406
expect(createCall.max_tokens).toBe(2048);
405407
});
406408

409+
it("retries with max_completion_tokens when the model rejects max_tokens (issue #60)", async () => {
410+
const rejection = Object.assign(
411+
new Error(
412+
"400 Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead."
413+
),
414+
{ status: 400, param: "max_tokens", code: "unsupported_parameter" }
415+
);
416+
mockCreate.mockRejectedValueOnce(rejection).mockResolvedValue(
417+
makeStream([
418+
{ choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
419+
{ choices: [{ delta: {}, finish_reason: "stop" }] },
420+
])
421+
);
422+
423+
const result = await engine.chat({ message: "Hi", userId: "user-1" });
424+
425+
expect(result.response).toBe("Hello");
426+
expect(mockCreate.mock.calls[0][0].max_tokens).toBe(2048);
427+
expect(mockCreate.mock.calls[0][0].max_completion_tokens).toBeUndefined();
428+
expect(mockCreate.mock.calls[1][0].max_completion_tokens).toBe(2048);
429+
expect(mockCreate.mock.calls[1][0].max_tokens).toBeUndefined();
430+
});
431+
407432
it("fires extractAndStoreFacts after response", async () => {
408433
mockCreate.mockResolvedValue(
409434
makeStream([

server/src/home-mind-server/src/llm/openai-client.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { buildSystemPromptText } from "./prompts.js";
99
import { TOOL_DEFINITIONS, toOpenAITools } from "./tool-definitions.js";
1010
import { randomUUID } from "node:crypto";
1111
import { handleToolCall, extractAndStoreFacts, type ToolContext } from "./tool-handler.js";
12+
import { withTokenCap, usesMaxCompletionTokens } from "./token-cap.js";
1213
import type {
1314
ChatRequest,
1415
ChatResponse,
@@ -234,13 +235,18 @@ export class OpenAIChatEngine implements IChatEngine {
234235

235236
private classifyEmptyResponse(finishReason: string | null): ChatError {
236237
if (finishReason === "length") {
237-
return {
238-
code: "MAX_TOKENS_TRUNCATED",
239-
hint:
240-
"Response was cut off at max_tokens before the model finished. " +
238+
// On OpenAI's newer models the output cap also covers hidden reasoning
239+
// tokens, so a reasoning-heavy turn can exhaust it before writing a single
240+
// visible word. That is a different problem from a prompt being too large,
241+
// and it deserves a different instruction.
242+
const hint = usesMaxCompletionTokens(this.config.llmModel)
243+
? "The model used its entire output budget on internal reasoning and had " +
244+
"none left for the answer. Try a non-reasoning model, or a lower " +
245+
"reasoning effort if your provider exposes one."
246+
: "Response was cut off at max_tokens before the model finished. " +
241247
"If you're seeing this often, the conversation prompt may be too large " +
242-
"for the model's output budget — try a model with more output tokens.",
243-
};
248+
"for the model's output budget — try a model with more output tokens.";
249+
return { code: "MAX_TOKENS_TRUNCATED", hint };
244250
}
245251
if (finishReason === "content_filter") {
246252
return {
@@ -271,16 +277,21 @@ export class OpenAIChatEngine implements IChatEngine {
271277
finishReason: string | null;
272278
toolCalls: FunctionToolCall[];
273279
}> {
274-
const stream = await this.client.chat.completions.create({
275-
model: this.config.llmModel,
276-
max_tokens: isVoice ? 500 : 2048,
277-
messages,
278-
tools: OPENAI_TOOLS,
279-
// Keep the tool list in the request (history already references it) but
280-
// stop the model from issuing more calls.
281-
...(disableTools ? { tool_choice: "none" as const } : {}),
282-
stream: true,
283-
});
280+
const stream = await withTokenCap(
281+
this.config.llmModel,
282+
isVoice ? 500 : 2048,
283+
(cap) =>
284+
this.client.chat.completions.create({
285+
model: this.config.llmModel,
286+
...cap,
287+
messages,
288+
tools: OPENAI_TOOLS,
289+
// Keep the tool list in the request (history already references it) but
290+
// stop the model from issuing more calls.
291+
...(disableTools ? { tool_choice: "none" as const } : {}),
292+
stream: true,
293+
})
294+
);
284295

285296
let text = "";
286297
let finishReason: string | null = null;
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import {
3+
withTokenCap,
4+
isMaxTokensUnsupported,
5+
resetTokenCapCache,
6+
} from "./token-cap.js";
7+
8+
/** The shape the OpenAI SDK throws for GPT-5 / o-series when sent `max_tokens`. */
9+
function unsupportedParamError() {
10+
return Object.assign(
11+
new Error(
12+
"400 Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead."
13+
),
14+
{
15+
status: 400,
16+
param: "max_tokens",
17+
code: "unsupported_parameter",
18+
type: "invalid_request_error",
19+
}
20+
);
21+
}
22+
23+
describe("isMaxTokensUnsupported", () => {
24+
it("recognises the structured OpenAI rejection", () => {
25+
expect(isMaxTokensUnsupported(unsupportedParamError())).toBe(true);
26+
});
27+
28+
it("recognises a proxy that keeps the message but drops the fields", () => {
29+
const err = Object.assign(new Error("Use 'max_completion_tokens' instead."), {
30+
status: 400,
31+
});
32+
expect(isMaxTokensUnsupported(err)).toBe(true);
33+
});
34+
35+
it("ignores unrelated 400s so they surface to the user", () => {
36+
const err = Object.assign(new Error("model not found"), {
37+
status: 400,
38+
code: "model_not_found",
39+
});
40+
expect(isMaxTokensUnsupported(err)).toBe(false);
41+
});
42+
43+
it("ignores non-400 failures", () => {
44+
const err = Object.assign(new Error("rate limited"), {
45+
status: 429,
46+
param: "max_tokens",
47+
code: "unsupported_parameter",
48+
});
49+
expect(isMaxTokensUnsupported(err)).toBe(false);
50+
});
51+
52+
it("ignores non-objects", () => {
53+
expect(isMaxTokensUnsupported("boom")).toBe(false);
54+
expect(isMaxTokensUnsupported(null)).toBe(false);
55+
});
56+
});
57+
58+
describe("withTokenCap", () => {
59+
beforeEach(() => {
60+
resetTokenCapCache();
61+
});
62+
63+
it("sends max_tokens and does not retry when the endpoint accepts it", async () => {
64+
const send = vi.fn().mockResolvedValue("ok");
65+
66+
await expect(withTokenCap("gpt-4o-mini", 2048, send)).resolves.toBe("ok");
67+
68+
expect(send).toHaveBeenCalledTimes(1);
69+
expect(send).toHaveBeenCalledWith({ max_tokens: 2048 });
70+
});
71+
72+
it("retries with max_completion_tokens when the model rejects max_tokens", async () => {
73+
const send = vi
74+
.fn()
75+
.mockRejectedValueOnce(unsupportedParamError())
76+
.mockResolvedValueOnce("ok");
77+
78+
await expect(withTokenCap("gpt-5.6", 2048, send)).resolves.toBe("ok");
79+
80+
expect(send).toHaveBeenCalledTimes(2);
81+
expect(send).toHaveBeenNthCalledWith(1, { max_tokens: 2048 });
82+
expect(send).toHaveBeenNthCalledWith(2, { max_completion_tokens: 2048 });
83+
});
84+
85+
it("remembers the answer, so only the first call pays the extra round-trip", async () => {
86+
const send = vi
87+
.fn()
88+
.mockRejectedValueOnce(unsupportedParamError())
89+
.mockResolvedValue("ok");
90+
91+
await withTokenCap("gpt-5.6", 500, send);
92+
send.mockClear();
93+
await withTokenCap("gpt-5.6", 500, send);
94+
95+
expect(send).toHaveBeenCalledTimes(1);
96+
expect(send).toHaveBeenCalledWith({ max_completion_tokens: 500 });
97+
});
98+
99+
it("learns per model, not globally", async () => {
100+
const rejecting = vi
101+
.fn()
102+
.mockRejectedValueOnce(unsupportedParamError())
103+
.mockResolvedValue("ok");
104+
await withTokenCap("gpt-5.6", 500, rejecting);
105+
106+
const other = vi.fn().mockResolvedValue("ok");
107+
await withTokenCap("openai/gpt-5.6-luna", 500, other);
108+
109+
expect(other).toHaveBeenCalledTimes(1);
110+
expect(other).toHaveBeenCalledWith({ max_tokens: 500 });
111+
});
112+
113+
it("propagates unrelated errors without a retry", async () => {
114+
const err = Object.assign(new Error("model not found"), { status: 400 });
115+
const send = vi.fn().mockRejectedValue(err);
116+
117+
await expect(withTokenCap("nope", 500, send)).rejects.toThrow("model not found");
118+
expect(send).toHaveBeenCalledTimes(1);
119+
});
120+
121+
it("does not loop when the retry itself fails", async () => {
122+
const send = vi.fn().mockRejectedValue(unsupportedParamError());
123+
124+
await expect(withTokenCap("gpt-5.6", 500, send)).rejects.toThrow();
125+
expect(send).toHaveBeenCalledTimes(2);
126+
});
127+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Output-token cap, negotiated rather than guessed.
3+
*
4+
* OpenAI's newer models (GPT-5 family, o-series) reject `max_tokens` outright:
5+
*
6+
* 400 Unsupported parameter: 'max_tokens' is not supported with this model.
7+
* Use 'max_completion_tokens' instead.
8+
*
9+
* Every other endpoint we support still wants `max_tokens` — OpenRouter, Ollama,
10+
* LM Studio, llama.cpp and the various OpenAI-compatible shims. So we cannot
11+
* simply switch, and we deliberately do NOT sniff the model name: the same model
12+
* takes different parameters depending on who is serving it (`openai/gpt-5.x` via
13+
* OpenRouter accepts `max_tokens` happily), and a name list would need editing
14+
* every time OpenAI ships a model.
15+
*
16+
* Instead: send `max_tokens`, and if the endpoint tells us it wants the other
17+
* spelling, send it again the other way and remember the answer for the rest of
18+
* the process. The probe costs one round-trip per model, only ever on the first
19+
* call, and it is a 400 — no tokens are billed for it. Endpoints that work today
20+
* see no change at all.
21+
*
22+
* Retrying is safe for streaming calls because the 400 arrives on the initial
23+
* request, before any chunk has been produced or forwarded to the caller.
24+
*/
25+
26+
/** Models known — from their own error response — to need `max_completion_tokens`. */
27+
const needsMaxCompletionTokens = new Set<string>();
28+
29+
export type TokenCapParam =
30+
| { max_tokens: number }
31+
| { max_completion_tokens: number };
32+
33+
/**
34+
* True when the error is an endpoint telling us `max_tokens` is the wrong
35+
* spelling. Kept narrow on purpose: a generic 400 (bad model name, malformed
36+
* tool schema) must fall through and surface to the user unchanged.
37+
*/
38+
export function isMaxTokensUnsupported(error: unknown): boolean {
39+
if (typeof error !== "object" || error === null) return false;
40+
const err = error as { status?: number; param?: string; code?: string; message?: string };
41+
if (err.status !== 400) return false;
42+
if (err.param === "max_tokens" && err.code === "unsupported_parameter") return true;
43+
// Providers that proxy OpenAI often keep the message but drop the structured
44+
// fields, so fall back to the one phrase that is unambiguous.
45+
return typeof err.message === "string" && err.message.includes("max_completion_tokens");
46+
}
47+
48+
/**
49+
* Run `send` with whichever output-cap parameter this model accepts, learning
50+
* the answer from the endpoint on first use.
51+
*/
52+
export async function withTokenCap<T>(
53+
model: string,
54+
maxTokens: number,
55+
send: (cap: TokenCapParam) => Promise<T>
56+
): Promise<T> {
57+
if (needsMaxCompletionTokens.has(model)) {
58+
return send({ max_completion_tokens: maxTokens });
59+
}
60+
61+
try {
62+
return await send({ max_tokens: maxTokens });
63+
} catch (error) {
64+
if (!isMaxTokensUnsupported(error)) throw error;
65+
needsMaxCompletionTokens.add(model);
66+
console.info(
67+
`[llm] ${model} rejects max_tokens; using max_completion_tokens for this model from now on.`
68+
);
69+
return send({ max_completion_tokens: maxTokens });
70+
}
71+
}
72+
73+
/**
74+
* True once this model has told us it wants `max_completion_tokens` — i.e. it is
75+
* one of OpenAI's newer models, where the cap covers reasoning tokens as well as
76+
* the visible answer. Used only to explain a truncation accurately.
77+
*/
78+
export function usesMaxCompletionTokens(model: string): boolean {
79+
return needsMaxCompletionTokens.has(model);
80+
}
81+
82+
/** Test seam — the learned set is process-global by design. */
83+
export function resetTokenCapCache(): void {
84+
needsMaxCompletionTokens.clear();
85+
}

server/src/home-mind-server/src/memory/openai-extractor.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import OpenAI from "openai";
22
import type { ExtractedFact, Fact } from "./types.js";
33
import type { IFactExtractor } from "../llm/interface.js";
44
import { EXTRACTION_PROMPT, VALID_CATEGORIES } from "./extraction-prompt.js";
5+
import { withTokenCap } from "../llm/token-cap.js";
56

67
export class OpenAIFactExtractor implements IFactExtractor {
78
private client: OpenAI;
@@ -56,14 +57,16 @@ ${JSON.stringify(factsJson, null, 2)}`;
5657
.replace("{user_message}", userMessage)
5758
.replace("{assistant_response}", assistantResponse);
5859

59-
const response = await this.client.chat.completions.create({
60-
model: this.model,
61-
max_tokens: this.maxTokens,
62-
messages: [{ role: "user", content: prompt }],
63-
...(this.responseFormat
64-
? { response_format: { type: this.responseFormat } }
65-
: {}),
66-
});
60+
const response = await withTokenCap(this.model, this.maxTokens, (cap) =>
61+
this.client.chat.completions.create({
62+
model: this.model,
63+
...cap,
64+
messages: [{ role: "user", content: prompt }],
65+
...(this.responseFormat
66+
? { response_format: { type: this.responseFormat } }
67+
: {}),
68+
})
69+
);
6770

6871
const text = response.choices[0]?.message?.content ?? "";
6972

0 commit comments

Comments
 (0)