diff --git a/.env.template b/.env.template index 72ca895..78a32cb 100644 --- a/.env.template +++ b/.env.template @@ -14,6 +14,16 @@ # OPENAI_BASE_URL=https://api.openai.com/v1 # GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # +# Optional: token-limit parameter name for OpenAI-compatible chat calls. +# Newer OpenAI models (gpt-5.x, o-series) reject the legacy `max_tokens` +# parameter — set this to send `max_completion_tokens` instead. +# OPENAI_MAX_TOKENS_PARAM=max_completion_tokens +# +# Optional: comma-separated request parameters to omit from OpenAI-compatible +# chat calls. gpt-5.x/o-series reasoning models also reject a non-default +# temperature (only the default 1 is accepted) — drop it from the request: +# OPENAI_DROP_PARAMS=temperature +# # OPTION B — Unified proxy endpoint (backwards compatible) # A single endpoint that routes all providers. Use this if you have a proxy # that serves multiple providers under one URL. diff --git a/README.md b/README.md index 27d62e9..720a205 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,8 @@ Provider-specific credentials take precedence over the unified endpoint. | `LLM_SYNTHESIS_MODEL` | *(inherits `LLM_EXTRACTION_MODEL`)* | Cross-session principle synthesis | | `LLM_TIMEOUT_MS` | `300000` | Per-call LLM timeout in ms | | `LLM_MAX_RETRIES` | `2` | Retry attempts on timeout/error | +| `OPENAI_MAX_TOKENS_PARAM` | `max_tokens` | Token-limit parameter sent on OpenAI-compatible calls. Set to `max_completion_tokens` for newer OpenAI models (gpt-5.x, o-series) that reject `max_tokens` | +| `OPENAI_DROP_PARAMS` | *(none)* | Comma-separated request parameters to omit from OpenAI-compatible calls (e.g. `temperature` for gpt-5.x/o-series models that only accept the default value) | ### Embeddings diff --git a/src/config.ts b/src/config.ts index 14aa9cb..875f660 100644 --- a/src/config.ts +++ b/src/config.ts @@ -197,6 +197,26 @@ export const config = { openai: { baseURL: process.env.OPENAI_BASE_URL || "", apiKey: process.env.OPENAI_API_KEY || "", + // Name of the request parameter carrying the output-token limit on + // OpenAI-compatible chat calls. The AI SDK always emits `max_tokens`; + // newer OpenAI models (gpt-5.x, o-series) reject it and require + // `max_completion_tokens` instead. Set OPENAI_MAX_TOKENS_PARAM to + // "max_completion_tokens" for those; anything else keeps the legacy + // default. Validated in validateConfig(). + maxTokensParam: + process.env.OPENAI_MAX_TOKENS_PARAM === "max_completion_tokens" + ? "max_completion_tokens" + : "max_tokens", + // Comma-separated request parameters to omit from OpenAI-compatible + // chat calls. Some models reject non-default sampling parameters + // outright — gpt-5.x/o-series reasoning models only accept the default + // temperature (1), so the hardcoded temperature in the request fails + // with HTTP 400. Typical usage: OPENAI_DROP_PARAMS=temperature + dropParams: process.env.OPENAI_DROP_PARAMS + ? process.env.OPENAI_DROP_PARAMS.split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], }, google: { baseURL: process.env.GOOGLE_BASE_URL || "", @@ -505,6 +525,19 @@ export function validateConfig(): string[] { } } + // Validate OPENAI_MAX_TOKENS_PARAM — only two values are meaningful. An + // invalid value would silently fall back to `max_tokens` at call time, + // leaving the user wondering why the setting has no effect. + if ( + process.env.OPENAI_MAX_TOKENS_PARAM !== undefined && + process.env.OPENAI_MAX_TOKENS_PARAM !== "max_tokens" && + process.env.OPENAI_MAX_TOKENS_PARAM !== "max_completion_tokens" + ) { + errors.push( + `OPENAI_MAX_TOKENS_PARAM must be "max_tokens" or "max_completion_tokens" (got "${process.env.OPENAI_MAX_TOKENS_PARAM}").`, + ); + } + // Warn when a dedicated embedding endpoint is configured but no API key is // available. This silently produces empty-string credentials and causes // authentication failures at runtime — surface it early at startup instead. diff --git a/src/consolidation/llm.ts b/src/consolidation/llm.ts index e23a660..b5b4a11 100644 --- a/src/consolidation/llm.ts +++ b/src/consolidation/llm.ts @@ -93,6 +93,43 @@ function createAnthropicOAuthFetch(): typeof globalThis.fetch { }) as typeof globalThis.fetch; } +/** + * Request-body transform that renames `max_tokens` → `max_completion_tokens`. + * + * The Vercel AI SDK's OpenAI-compatible provider always emits `max_tokens`, + * but newer OpenAI models (gpt-5.x, o-series) reject that parameter and + * require `max_completion_tokens` instead. Installed on the provider via + * `transformRequestBody` when OPENAI_MAX_TOKENS_PARAM=max_completion_tokens + * is configured. Exported for testing. + */ +export function renameMaxTokensParam( + args: Record, +): Record { + if (args.max_tokens == null) return args; + const { max_tokens: maxTokens, ...rest } = args; + return { ...rest, max_completion_tokens: maxTokens }; +} + +/** + * Request-body transform that omits the given parameters. + * + * Some OpenAI-compatible models reject non-default sampling parameters + * outright — gpt-5.x/o-series reasoning models only accept the default + * temperature (1), so the hardcoded `temperature: 0.2` in complete() fails + * with HTTP 400. Installed on the provider via `transformRequestBody` with + * the OPENAI_DROP_PARAMS list. Exported for testing. + */ +export function omitRequestParams( + args: Record, + params: readonly string[], +): Record { + if (params.length === 0) return args; + const drop = new Set(params); + return Object.fromEntries( + Object.entries(args).filter(([key]) => !drop.has(key)), + ); +} + /** * Provider routing based on model string prefix. * @@ -160,10 +197,27 @@ function createModel(modelString: string) { (config.llm.baseEndpoint ? `${config.llm.baseEndpoint}/openai/v1` : "https://api.openai.com/v1"); + // Transport-boundary fixes for picky OpenAI-compatible models: + // gpt-5.x/o-series reject the legacy `max_tokens` parameter and/or + // non-default sampling parameters (e.g. temperature). The SDK always + // emits them; rewrite the body when configured — by default no + // transform is installed and the request passes through untouched. + const { maxTokensParam, dropParams } = config.llm.openai; const provider = createOpenAICompatible({ name: providerName, baseURL, apiKey, + ...(maxTokensParam === "max_completion_tokens" || dropParams.length > 0 + ? { + transformRequestBody: (args: Record) => { + let body = args; + if (maxTokensParam === "max_completion_tokens") { + body = renameMaxTokensParam(body); + } + return omitRequestParams(body, dropParams); + }, + } + : {}), }); return provider.chatModel(modelId); } diff --git a/tests/config.test.ts b/tests/config.test.ts index fb46e44..2864fd6 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -18,19 +18,20 @@ import { validateConfig } from "../src/config"; // ── env var helpers ─────────────────────────────────────────────────────────── -const FLOAT_VARS = [ +const ENV_VARS = [ "DECAY_ARCHIVE_THRESHOLD", "RECONSOLIDATION_SIMILARITY_THRESHOLD", "CONTRADICTION_MIN_SIMILARITY", "ACTIVATION_SIMILARITY_THRESHOLD", "EMBEDDING_DIMENSIONS", + "OPENAI_MAX_TOKENS_PARAM", ] as const; -type FloatVar = (typeof FLOAT_VARS)[number]; -const snapshot: Partial> = {}; +type EnvVar = (typeof ENV_VARS)[number]; +const snapshot: Partial> = {}; beforeEach(() => { - for (const v of FLOAT_VARS) { + for (const v of ENV_VARS) { snapshot[v] = process.env[v]; // Unset each optional validator so the baseline is clean for each test Reflect.deleteProperty(process.env, v); @@ -38,7 +39,7 @@ beforeEach(() => { }); afterEach(() => { - for (const v of FLOAT_VARS) { + for (const v of ENV_VARS) { if (snapshot[v] === undefined) { Reflect.deleteProperty(process.env, v); } else { @@ -47,18 +48,18 @@ afterEach(() => { } }); -/** Only the float-range and EMBEDDING_DIMENSIONS errors — filters out the +/** Only the errors for the env vars under test — filters out the * singleton-sourced errors (LLM_API_KEY, LLM_BASE_ENDPOINT, host, opencodeDbPath) * that depend on how the test process was launched. */ function envErrors(): string[] { - return validateConfig().filter((e) => FLOAT_VARS.some((v) => e.includes(v))); + return validateConfig().filter((e) => ENV_VARS.some((v) => e.includes(v))); } // ── baseline ────────────────────────────────────────────────────────────────── describe("validateConfig — float-range baseline (all optional vars unset)", () => { it("produces no float-range errors when optional vars are unset", () => { - // All FLOAT_VARS are deleted in beforeEach — validateConfig should skip them. + // All ENV_VARS are deleted in beforeEach — validateConfig should skip them. expect(envErrors()).toEqual([]); }); }); @@ -264,6 +265,38 @@ describe("validateConfig — EMBEDDING_DIMENSIONS", () => { }); }); +// ── OPENAI_MAX_TOKENS_PARAM ─────────────────────────────────────────────────── + +describe("validateConfig — OPENAI_MAX_TOKENS_PARAM", () => { + it("accepts max_tokens", () => { + process.env.OPENAI_MAX_TOKENS_PARAM = "max_tokens"; + expect( + envErrors().filter((e) => e.includes("OPENAI_MAX_TOKENS_PARAM")), + ).toHaveLength(0); + }); + + it("accepts max_completion_tokens", () => { + process.env.OPENAI_MAX_TOKENS_PARAM = "max_completion_tokens"; + expect( + envErrors().filter((e) => e.includes("OPENAI_MAX_TOKENS_PARAM")), + ).toHaveLength(0); + }); + + it("rejects an unknown parameter name", () => { + process.env.OPENAI_MAX_TOKENS_PARAM = "max_output_tokens"; + expect(envErrors().some((e) => e.includes("OPENAI_MAX_TOKENS_PARAM"))).toBe( + true, + ); + }); + + it("produces no error when OPENAI_MAX_TOKENS_PARAM is unset", () => { + // Already deleted in beforeEach + expect( + envErrors().filter((e) => e.includes("OPENAI_MAX_TOKENS_PARAM")), + ).toHaveLength(0); + }); +}); + // ── multiple errors accumulated ─────────────────────────────────────────────── describe("validateConfig — multiple errors accumulated", () => { diff --git a/tests/llm.test.ts b/tests/llm.test.ts index aa715da..e47d701 100644 --- a/tests/llm.test.ts +++ b/tests/llm.test.ts @@ -10,7 +10,12 @@ */ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import * as aiModule from "ai"; -import { ConsolidationLLM, formatEpisodes } from "../src/consolidation/llm"; +import { + ConsolidationLLM, + formatEpisodes, + omitRequestParams, + renameMaxTokensParam, +} from "../src/consolidation/llm"; import type { Episode } from "../src/types"; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -577,3 +582,59 @@ describe("ConsolidationLLM.synthesizePrinciple", () => { expect(results[1].type).toBe("pattern"); }); }); + +// ── renameMaxTokensParam ─────────────────────────────────────────────────────── + +describe("renameMaxTokensParam", () => { + it("renames max_tokens to max_completion_tokens, preserving the value", () => { + expect( + renameMaxTokensParam({ + model: "gpt-5.6-luna", + max_tokens: 8192, + temperature: 0.2, + }), + ).toEqual({ + model: "gpt-5.6-luna", + max_completion_tokens: 8192, + temperature: 0.2, + }); + }); + + it("leaves bodies without max_tokens untouched", () => { + const body = { model: "gpt-5.6-luna", messages: [] }; + expect(renameMaxTokensParam(body)).toEqual(body); + }); + + it("passes through an absent max_tokens instead of emitting an undefined value", () => { + expect(renameMaxTokensParam({ max_tokens: undefined })).toEqual({}); + }); +}); + +// ── omitRequestParams ───────────────────────────────────────────────────────── + +describe("omitRequestParams", () => { + it("removes the listed parameters, preserving the rest", () => { + expect( + omitRequestParams( + { + model: "gpt-5.6-luna", + temperature: 0.2, + top_p: 0.9, + max_completion_tokens: 8192, + }, + ["temperature", "top_p"], + ), + ).toEqual({ model: "gpt-5.6-luna", max_completion_tokens: 8192 }); + }); + + it("ignores parameters that are not present in the body", () => { + expect(omitRequestParams({ model: "m" }, ["temperature"])).toEqual({ + model: "m", + }); + }); + + it("returns the body unchanged when the list is empty", () => { + const body = { model: "m", temperature: 0.2 }; + expect(omitRequestParams(body, [])).toEqual(body); + }); +});