Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Reasoning models could not be used at all** — the OpenAI provider hard-coded `max_tokens` on all three completion paths, and the o-series and GPT-5 families reject it: `Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.` The failure came back on the first extraction request, before any page was written, so those models were unusable rather than degraded. The field is now selected from the model id, with `LLMWIKI_OPENAI_TOKEN_PARAM` to force it for gateways that serve a reasoning model under a private id. The SDK has carried `max_tokens` as deprecated in favour of `max_completion_tokens` since 6.x.

`reasoning_effort` follows the same shape. The GPT-5 family rejects a request carrying function tools unless the field is present, and every `compile` extraction call has exactly that shape — so those models now default to `reasoning_effort: none`, which is what makes them work out of the box rather than only after reading this entry. `none` because extraction and page generation are structured tool calls, where reasoning tokens buy latency and not accuracy. `LLMWIKI_OPENAI_REASONING_EFFORT` overrides it with any of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, on any model.

The default is deliberately not applied to the o-series: it accepts a tool-carrying request with the field absent, and rejects some of the values above, so guessing on its behalf would turn a working request into a 400. Set the variable to opt it in.

An unrecognised value for either variable fails immediately with the accepted values named, rather than as an opaque 400 mid-compile. Both defaults reproduce the previous request byte-for-byte for models that match no reasoning-family prefix.

- **Windows: profile path validation rejected every declared directory** — on win32, `llmwiki template init` failed for every template with `entity directory must be under 'wiki/'`, any profile declaring a workflow `projectionFile` failed to load, and an entity directory declared as `wiki/` was wrongly accepted despite containing every reserved subtree — on win32 it was the only entity directory that loaded at all. Declared directories canonicalize to `/`-joined repo-relative paths, but the containment check built its prefix with the platform separator (`\` on Windows), so no nested path ever matched. The lexical profile-path checks now compare POSIX paths directly; native path confinement is unchanged. Reported and diagnosed by @squ1ddy (#163).

- **Windows: broken links in the generated wiki index** — the same separator bug on the output side. Entity-page links in `wiki/index.md` are built from `path.relative`, which emits `\` on win32, so a NESTED entity directory produced the unusable link `research\papers/foo.md`. Link targets are now normalized to POSIX. Single-level directories were unaffected, which is why this went unnoticed (#163).
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ Either `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` satisfies authentication -
| `OPENAI_BASE_URL` | For custom or local endpoints | Base URL for chat and tool calls. **Must include `/v1`** |
| `OPENAI_EMBEDDINGS_BASE_URL` | No | Separate base URL for embeddings requests. When unset, embeddings use the same client and base URL as chat |
| `OPENAI_EMBEDDINGS_API_KEY` | No | Credential for embeddings requests. When unset, embeddings reuse `OPENAI_API_KEY` - set this whenever the embeddings endpoint belongs to someone else |
| `LLMWIKI_OPENAI_TOKEN_PARAM` | No | Force the token-limit field: `max_tokens` or `max_completion_tokens`. Detected from the model id by default (`o1`, `o3`, `o4`, and `gpt-5` prefixes get `max_completion_tokens`). Set it when a gateway serves a reasoning model under an id those prefixes do not match |
| `LLMWIKI_OPENAI_REASONING_EFFORT` | No | Send `reasoning_effort` on every chat request. One of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. Unset sends nothing. Some reasoning models reject a tool-carrying request that omits it |

<Note>
Reasoning models reject `max_tokens` outright — the request fails with `Unsupported parameter: 'max_tokens' is not supported with this model`. llmwiki picks the right field from the model id, so `LLMWIKI_OPENAI_TOKEN_PARAM` is only needed when the id itself does not reveal the model family, which is common behind OpenAI-compatible gateways.
</Note>

---

Expand Down
131 changes: 131 additions & 0 deletions src/providers/openai-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Request-shape adaptation for OpenAI-compatible chat completions.
*
* The Chat Completions body is not uniform across models any more. Reasoning
* models (the o-series and the GPT-5 family) reject `max_tokens` outright and
* require `max_completion_tokens`; the OpenAI SDK's own types have carried
* `max_tokens` as deprecated since 6.x. Several of those models also reject a
* request that carries function tools without `reasoning_effort`.
*
* Both facts are properties of the model, not of llmwiki, so this module keeps
* them in one place rather than spreading conditionals across the three call
* sites in the provider. Model-id prefixes cover models served directly by
* OpenAI; the env overrides exist because an OpenAI-compatible gateway can
* expose any of them under an id this module has never seen.
*
* Defaults reproduce the previous request byte-for-byte for every model that
* does not match a prefix.
*/

import type OpenAI from "openai";

/** Env override for the token-limit parameter, when prefix detection cannot see it. */
const TOKEN_PARAM_ENV = "LLMWIKI_OPENAI_TOKEN_PARAM";

/** Env slot carrying `reasoning_effort` for models that demand one. */
const REASONING_EFFORT_ENV = "LLMWIKI_OPENAI_REASONING_EFFORT";

/** The two spellings of the token limit, oldest first. */
const TOKEN_PARAMS = ["max_tokens", "max_completion_tokens"] as const;

type TokenParam = (typeof TOKEN_PARAMS)[number];

/**
* Model-id prefixes served by OpenAI that reject `max_tokens`.
*
* Matched case-insensitively against the start of the id. Gateways that
* re-badge these models are covered by TOKEN_PARAM_ENV instead — guessing from
* an arbitrary vendor id would misfire in both directions.
*/
const MAX_COMPLETION_TOKEN_PREFIXES = ["o1", "o3", "o4", "gpt-5"];

/**
* Accepted `reasoning_effort` values, mirroring the SDK's `ReasoningEffort`.
* Duplicated as a runtime set because the SDK exports it as a type only, and
* an unchecked env value would surface as an opaque 400 from the API.
*/
const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const;

/** Raised when an env override carries a value the API would reject. */
export class OpenAIRequestConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "OpenAIRequestConfigError";
}
}

/**
* The token-limit field for `model`, as a spreadable fragment of the request
* body. The env override wins over prefix detection so a gateway id can be
* corrected without a release.
*/
export function tokenLimitParams(
model: string,
maxTokens: number,
): Pick<OpenAI.ChatCompletionCreateParams, "max_tokens" | "max_completion_tokens"> {
return resolveTokenParam(model) === "max_completion_tokens"
? { max_completion_tokens: maxTokens }
: { max_tokens: maxTokens };
}

/**
* The `reasoning_effort` fragment for `model`, or nothing when neither the
* model nor the operator asks for one — so a request for a model with no
* opinion on reasoning is unchanged.
*/
export function reasoningParams(
model: string,
): Pick<OpenAI.ChatCompletionCreateParams, "reasoning_effort"> | object {
const raw = process.env[REASONING_EFFORT_ENV]?.trim().toLowerCase();
if (!raw) return defaultReasoningParams(model);
if (!(REASONING_EFFORTS as readonly string[]).includes(raw)) {
throw new OpenAIRequestConfigError(
`${REASONING_EFFORT_ENV} must be one of ${REASONING_EFFORTS.join(", ")} (got "${raw}")`,
);
}
return { reasoning_effort: raw as OpenAI.ReasoningEffort };
}

/**
* The effort a model needs when nobody configured one.
*
* Deliberately narrower than MAX_COMPLETION_TOKEN_PREFIXES. The GPT-5 family
* rejects a request carrying function tools unless `reasoning_effort` is
* present, and llmwiki's extraction pass always sends tools — so without this
* the very first call fails. The o-series accepts a request with the field
* absent, and does not accept every value listed in REASONING_EFFORTS, so
* guessing on its behalf would trade a working default for a 400.
*
* `none` rather than a thinking budget because extraction and page generation
* are structured tool calls, where reasoning tokens cost latency without
* improving the result. Override with REASONING_EFFORT_ENV to buy thinking back.
*/
const DEFAULT_REASONING_EFFORT_PREFIXES = ["gpt-5"];

/** The default effort for a model id, or nothing when it needs no opinion. */
function defaultReasoningParams(
model: string,
): Pick<OpenAI.ChatCompletionCreateParams, "reasoning_effort"> | object {
const id = model.toLowerCase();
return DEFAULT_REASONING_EFFORT_PREFIXES.some(prefix => id.startsWith(prefix))
? { reasoning_effort: "none" as OpenAI.ReasoningEffort }
: {};
}

/** Resolve the token parameter from the env override, else from the model id. */
function resolveTokenParam(model: string): TokenParam {
const override = process.env[TOKEN_PARAM_ENV]?.trim();
if (override) return readTokenParamOverride(override);
const id = model.toLowerCase();
return MAX_COMPLETION_TOKEN_PREFIXES.some(prefix => id.startsWith(prefix))
? "max_completion_tokens"
: "max_tokens";
}

/** Validate the env override, naming both spellings so a typo is obvious. */
function readTokenParamOverride(value: string): TokenParam {
if ((TOKEN_PARAMS as readonly string[]).includes(value)) return value as TokenParam;
throw new OpenAIRequestConfigError(
`${TOKEN_PARAM_ENV} must be one of ${TOKEN_PARAMS.join(", ")} (got "${value}")`,
);
}
32 changes: 23 additions & 9 deletions src/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { LLMProvider, LLMMessage, LLMTool } from "../utils/provider.js";
import { EMBEDDING_MODELS, OPENAI_DEFAULT_TIMEOUT_MS } from "../utils/constants.js";
import * as output from "../utils/output.js";
import { assertVectorValid, normalizeEmbeddingData } from "../utils/embeddings-validate.js";
import { reasoningParams, tokenLimitParams } from "./openai-request.js";

/** Construction options for an OpenAI-compatible provider. */
interface OpenAIProviderOptions {
Expand Down Expand Up @@ -208,14 +209,31 @@ export class OpenAIProvider implements LLMProvider {
/** Send a single non-streaming completion request. */
async complete(system: string, messages: LLMMessage[], maxTokens: number): Promise<string> {
const response = await this.client.chat.completions.create({
model: this.model,
max_tokens: maxTokens,
messages: [{ role: "system", content: system }, ...messages],
...this.requestBase(system, messages, maxTokens),
});

return response.choices[0]?.message?.content ?? "";
}

/**
* The request fields every completion shares, with the token limit spelled
* the way this model accepts it and `reasoning_effort` attached when the
* project configured one. Kept in one place so the three call sites below
* cannot drift apart.
*/
private requestBase(
system: string,
messages: LLMMessage[],
maxTokens: number,
): OpenAI.ChatCompletionCreateParamsNonStreaming {
return {
model: this.model,
...tokenLimitParams(this.model, maxTokens),
...reasoningParams(this.model),
messages: [{ role: "system", content: system }, ...messages],
};
}

/** Stream a completion, invoking onToken for each text chunk. */
async stream(
system: string,
Expand All @@ -224,9 +242,7 @@ export class OpenAIProvider implements LLMProvider {
onToken?: (text: string) => void,
): Promise<string> {
const stream = await this.client.chat.completions.create({
model: this.model,
max_tokens: maxTokens,
messages: [{ role: "system", content: system }, ...messages],
...this.requestBase(system, messages, maxTokens),
stream: true,
});

Expand All @@ -252,9 +268,7 @@ export class OpenAIProvider implements LLMProvider {
const openaiTools = tools.map(translateToolToOpenAI);

const response = await this.client.chat.completions.create({
model: this.model,
max_tokens: maxTokens,
messages: [{ role: "system", content: system }, ...messages],
...this.requestBase(system, messages, maxTokens),
tools: openaiTools,
tool_choice: "required",
});
Expand Down
143 changes: 143 additions & 0 deletions test/openai-request-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* @file test/openai-request-shape.test.ts
* @description Which token-limit field and reasoning parameters reach the Chat
* Completions API.
*
* Reasoning models reject `max_tokens` and require `max_completion_tokens`, and
* some reject a tool-carrying request that omits `reasoning_effort`. Detection
* is by model-id prefix, with env overrides for OpenAI-compatible gateways that
* re-badge those models under ids no prefix list can anticipate.
*
* The provider cases matter as much as the unit ones: the three completion
* methods each built their own body before, so the regression this guards is
* one of them drifting back to a hard-coded `max_tokens`.
*/

import { describe, it, expect, afterEach } from "vitest";
import {
OpenAIRequestConfigError,
reasoningParams,
tokenLimitParams,
} from "../src/providers/openai-request.js";
import { OpenAIProvider } from "../src/providers/openai.js";
import { createEnvSnapshot } from "./fixtures/env-snapshot.js";

const TOKEN_PARAM_ENV = "LLMWIKI_OPENAI_TOKEN_PARAM";
const REASONING_EFFORT_ENV = "LLMWIKI_OPENAI_REASONING_EFFORT";

const { setEnv, restore } = createEnvSnapshot([TOKEN_PARAM_ENV, REASONING_EFFORT_ENV]);

afterEach(restore);

/** Capture the body the provider hands the SDK, without any network call. */
function captureRequest(provider: OpenAIProvider): Record<string, unknown>[] {
const bodies: Record<string, unknown>[] = [];
const client = Reflect.get(provider, "client") as {
chat: { completions: { create: unknown } };
};
client.chat.completions.create = async (body: Record<string, unknown>) => {
bodies.push(body);
return { choices: [{ message: { content: "ok" } }] };
};
return bodies;
}

describe("tokenLimitParams", () => {
it("keeps max_tokens for classic chat models", () => {
expect(tokenLimitParams("gpt-4o", 100)).toEqual({ max_tokens: 100 });
});

it.each(["o1", "o3-mini", "o4-mini", "gpt-5.6", "GPT-5-turbo"])(
"uses max_completion_tokens for %s",
model => {
expect(tokenLimitParams(model, 100)).toEqual({ max_completion_tokens: 100 });
},
);

it("lets the env override force the new spelling for a gateway id", () => {
setEnv({ [TOKEN_PARAM_ENV]: "max_completion_tokens" });
expect(tokenLimitParams("vendor-private-model", 100)).toEqual({
max_completion_tokens: 100,
});
});

it("lets the env override force the old spelling for a matching prefix", () => {
setEnv({ [TOKEN_PARAM_ENV]: "max_tokens" });
expect(tokenLimitParams("gpt-5.6", 100)).toEqual({ max_tokens: 100 });
});

it("rejects an unknown override instead of sending it", () => {
setEnv({ [TOKEN_PARAM_ENV]: "maxTokens" });
expect(() => tokenLimitParams("gpt-4o", 100)).toThrow(OpenAIRequestConfigError);
});
});

describe("reasoningParams", () => {
it("sends nothing for a model with no opinion on reasoning", () => {
expect(reasoningParams("gpt-4o-mini")).toEqual({});
});

it.each(["gpt-5", "gpt-5.6-luna", "GPT-5-mini"])(
"defaults %s to none, because it rejects tools without an effort",
model => {
expect(reasoningParams(model)).toEqual({ reasoning_effort: "none" });
},
);

it("leaves the o-series alone rather than guessing a value it may reject", () => {
expect(reasoningParams("o3-mini")).toEqual({});
});

it.each(["none", "minimal", "low", "medium", "high", "xhigh"])(
"passes %s through",
effort => {
setEnv({ [REASONING_EFFORT_ENV]: effort });
expect(reasoningParams("gpt-4o-mini")).toEqual({ reasoning_effort: effort });
},
);

it("lets the override win over the model default", () => {
setEnv({ [REASONING_EFFORT_ENV]: "high" });
expect(reasoningParams("gpt-5.6-luna")).toEqual({ reasoning_effort: "high" });
});

it("normalizes whitespace and case", () => {
setEnv({ [REASONING_EFFORT_ENV]: " NONE " });
expect(reasoningParams("gpt-4o-mini")).toEqual({ reasoning_effort: "none" });
});

it("rejects an unknown effort instead of sending it", () => {
setEnv({ [REASONING_EFFORT_ENV]: "maximum" });
expect(() => reasoningParams("gpt-4o-mini")).toThrow(OpenAIRequestConfigError);
});
});

describe("OpenAIProvider request bodies", () => {
it("sends max_tokens for a classic model", async () => {
const provider = new OpenAIProvider("gpt-4o", { apiKey: "test" });
const bodies = captureRequest(provider);
await provider.complete("system", [], 512);
expect(bodies[0]).toMatchObject({ max_tokens: 512 });
expect(bodies[0]).not.toHaveProperty("max_completion_tokens");
});

it("sends max_completion_tokens for a reasoning model", async () => {
const provider = new OpenAIProvider("gpt-5.6", { apiKey: "test" });
const bodies = captureRequest(provider);
await provider.complete("system", [], 512);
expect(bodies[0]).toMatchObject({ max_completion_tokens: 512 });
expect(bodies[0]).not.toHaveProperty("max_tokens");
});

it("applies the same shape on the tool-call path, keeping tool_choice", async () => {
setEnv({ [REASONING_EFFORT_ENV]: "none" });
const provider = new OpenAIProvider("gpt-5.6", { apiKey: "test" });
const bodies = captureRequest(provider);
await provider.toolCall("system", [], [], 512);
expect(bodies[0]).toMatchObject({
max_completion_tokens: 512,
reasoning_effort: "none",
tool_choice: "required",
});
});
});
Loading