Skip to content

graft build --deep fails with GPT-5.6 / o-series reasoning models (max_tokens, temperature, tool+reasoning_effort) #183

Description

@nguyenduyhung1989

Summary

graft build --deep fails outright when pointed at OpenAI's current reasoning-family models (tested with gpt-5.6-luna via GRAFT_PROVIDER=openai). Three separate 400s come back from src/ai/llm/openai.ts, each because the adapter sends a Chat Completions parameter that this model family rejects. All three block the whole --deep pass (summarize, crux, and synthesize all use the same adapter).

Environment

  • graft 0.12.0 (built from source, this repo)
  • GRAFT_PROVIDER=openai, GRAFT_MODEL=gpt-5.6-luna, default api.openai.com base URL
  • Real repo, ~150 TS/TSX files, default --concurrency

Errors observed, in the order they surface

  1. max_tokens

    400 Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.
    

    src/ai/llm/openai.ts unconditionally sets params.max_tokens = req.maxTokens (line ~111). This model family requires max_completion_tokens.

  2. temperature (surfaces once docs: rewrite README around Graft #1 is worked around)

    400 Unsupported value: 'temperature' does not support 0 with this model. Only the default (1) value is supported.
    

    summarize.ts / crux.ts / synthesize.ts all hardcode temperature: 0 for determinism. This model only accepts the default (no temperature field at all).

  3. Tool calls + reasoning effort (surfaces once docs: rewrite README around Graft #1 and graft viz: interactive context-graph visualizer #2 are worked around)

    400 Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.
    

    Both crux.ts and synthesize.ts force tool-calling (responseFormat: { kind: "tool" }). On /v1/chat/completions, this model's default reasoning effort conflicts with function tools; the API's own error message names the fix.

Suggested fix

createChatCompletion already has a narrow, error-message-driven fallback for one OpenAI-compatible quirk (isRejectedObjectToolChoice → retry with tool_choice: "required"). The same pattern extends cleanly to all three errors above — detect the specific 400 by message, retry once with the corrected param, never guess from the model name (so older endpoints/models that still expect max_tokens/custom temperature are untouched).

I have a working patch against src/ai/llm/openai.ts doing exactly this (bounded retry loop, one fix applied per detected error, falls through to the original error if nothing matches):

--- a/src/ai/llm/openai.ts
+++ b/src/ai/llm/openai.ts
@@ -85,6 +85,43 @@ function isRejectedObjectToolChoice(err: unknown): boolean {
   );
 }
 
+/**
+ * Newer reasoning-family models (o1/o3/o4, the gpt-5.x line, …) reject the
+ * classic `max_tokens` param outright and require `max_completion_tokens`
+ * instead, even though both are still in wide use across OpenAI-compatible
+ * servers. Detect the specific 400 rather than guessing from the model name,
+ * so older endpoints that still expect `max_tokens` are untouched.
+ */
+function isRejectedMaxTokens(err: unknown): boolean {
+  return (
+    err instanceof OpenAI.APIError &&
+    err.status === 400 &&
+    /max_tokens.*not supported.*max_completion_tokens/i.test(String((err as { message?: string }).message ?? ""))
+  );
+}
+
+/** Same reasoning-family models: temperature is fixed at 1, not caller-settable. */
+function isRejectedTemperature(err: unknown): boolean {
+  return (
+    err instanceof OpenAI.APIError &&
+    err.status === 400 &&
+    /temperature.*does not support/i.test(String((err as { message?: string }).message ?? ""))
+  );
+}
+
+/**
+ * Same models again: function tools are rejected on /v1/chat/completions while
+ * the model's default reasoning effort is active. The API's own error message
+ * names the fix — `reasoning_effort: "none"` — so apply exactly that.
+ */
+function isRejectedToolsWithReasoning(err: unknown): boolean {
+  return (
+    err instanceof OpenAI.APIError &&
+    err.status === 400 &&
+    /function tools with reasoning_effort/i.test(String((err as { message?: string }).message ?? ""))
+  );
+}
+
 export class OpenAIChatModel implements ChatModel {
   readonly label: string;
   private client: OpenAI;
@@ -141,14 +178,34 @@ export class OpenAIChatModel implements ChatModel {
    * automatically.
    */
   private async createChatCompletion(params: ChatParams): Promise<OpenAI.Chat.Completions.ChatCompletion> {
-    try {
-      return await this.client.chat.completions.create(params);
-    } catch (err) {
-      if (isRejectedObjectToolChoice(err) && typeof params.tool_choice === "object" && params.tools?.length === 1) {
-        return this.client.chat.completions.create({ ...params, tool_choice: "required" });
+    let attempt = params;
+    // Bounded: one retry per known incompatibility below, never an open loop.
+    for (let i = 0; i < 4; i++) {
+      try {
+        return await this.client.chat.completions.create(attempt);
+      } catch (err) {
+        if (isRejectedObjectToolChoice(err) && typeof attempt.tool_choice === "object" && attempt.tools?.length === 1) {
+          attempt = { ...attempt, tool_choice: "required" };
+          continue;
+        }
+        if (isRejectedMaxTokens(err) && attempt.max_tokens !== undefined) {
+          const { max_tokens, ...rest } = attempt;
+          attempt = { ...rest, max_completion_tokens: max_tokens } as ChatParams;
+          continue;
+        }
+        if (isRejectedTemperature(err) && attempt.temperature !== undefined) {
+          const { temperature, ...rest } = attempt;
+          attempt = rest as ChatParams;
+          continue;
+        }
+        if (isRejectedToolsWithReasoning(err)) {
+          attempt = { ...attempt, reasoning_effort: "none" } as ChatParams;
+          continue;
+        }
+        throw err;
       }
-      throw err;
     }
+    return this.client.chat.completions.create(attempt);
   }
 
   private fromResponse(

Happy to open this as a PR instead if that's more useful than a patch pasted into an issue — let me know.

Unrelated but adjacent: rate limits on lower tiers

Not a graft bug, just a heads-up: gpt-5.6-luna on a tier-1/2 org caps at 200k TPM, separate from any daily token allowance. The default --concurrency 5 for the deep pass burst past that within the first few seconds on a ~150-file repo, and the existing "5 consecutive failures → abort" circuit breaker (ai/failure.ts) then killed the whole run rather than backing off. Dropping to --concurrency 2 worked around it. Might be worth a line in the README/--deep docs about lowering --concurrency for lower-tier accounts, since the abort-on-429 behavior otherwise looks like a hard failure rather than a pacing issue.

Result once patched

With the patch above, --deep completed cleanly end-to-end and produced genuinely useful concept nodes from a real repo — e.g. one node's generated summary: "A snapshot download plan is a constrained projection of repository state: every selected blob must be declared by the manifest, reside under wiki/public, and be a supported regular file; private paths, traversal, symlinks, unsupported files, missing entries, and incomplete trees must never enter the plan." So the fix is worth landing — the model family works well once these three params are handled.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions