Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "",
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions src/consolidation/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
params: readonly string[],
): Record<string, unknown> {
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.
*
Expand Down Expand Up @@ -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<string, unknown>) => {
let body = args;
if (maxTokensParam === "max_completion_tokens") {
body = renameMaxTokensParam(body);
}
return omitRequestParams(body, dropParams);
},
}
: {}),
});
return provider.chatModel(modelId);
}
Expand Down
49 changes: 41 additions & 8 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,28 @@ 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<Record<FloatVar, string>> = {};
type EnvVar = (typeof ENV_VARS)[number];
const snapshot: Partial<Record<EnvVar, string>> = {};

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);
}
});

afterEach(() => {
for (const v of FLOAT_VARS) {
for (const v of ENV_VARS) {
if (snapshot[v] === undefined) {
Reflect.deleteProperty(process.env, v);
} else {
Expand All @@ -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([]);
});
});
Expand Down Expand Up @@ -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", () => {
Expand Down
63 changes: 62 additions & 1 deletion tests/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
});
});
Loading