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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@
# GRAFT_API_KEY=sk-orca-...
# GRAFT_MODEL=openai/gpt-4o-mini

# How much hidden reasoning a reasoning-capable model spends: none | minimal |
# low | medium | high. Unset leaves the model's own default. Set "none" if a
# server burns the whole token budget reasoning and returns empty summaries.
# GRAFT_REASONING_EFFORT=none
#
# Escape hatch for a parameter the OpenAI schema has no name for, or one your
# gateway wants in a different place. JSON, merged into the request body:
# LiteLLM proxy (drops a top-level reasoning_effort, forwards extra_body):
# GRAFT_LLM_EXTRA_BODY={"extra_body":{"reasoning_effort":"none"}}
# vLLM reached directly (its own chat-template switch):
# GRAFT_LLM_EXTRA_BODY={"chat_template_kwargs":{"enable_thinking":false}}
# GRAFT_LLM_EXTRA_BODY=

# --- Deprecated (still honored as a fallback) ---------------------------------
# If GRAFT_API_KEY is unset, these legacy OpenRouter vars are used instead.
# OPENROUTER_API_KEY=sk-or-...
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ _Summary, sources, links, and notes ship today in markdown nodes. The crux ships
## What runs where

- **On your machine, no key, no network:** the structural code graph. `graft build` (wiring graph + per-file cards), `graft check`, and `graft ask` are deterministic tree-sitter — they never call a model.
- **Through your provider key:** the LLM-written parts — `graft build --deep` adds the concept nodes (file summaries + node synthesis) and the per-symbol summaries and cruxes. graft is vendor-neutral: set `GRAFT_PROVIDER` (`openai` for any OpenAI-compatible endpoint, `anthropic` for the native API, or `litellm` / `orcarouter` for a gateway that speaks the OpenAI-compatible format), your `GRAFT_API_KEY`, `GRAFT_MODEL`, and — for the `openai` wire format — `GRAFT_BASE_URL` to point at OpenRouter, Fireworks, Groq, a LiteLLM proxy, a local server, or OpenAI itself. Or pass `--provider/--model/--api-key/--base-url` on the command line. (`OPENROUTER_API_KEY` still works as a deprecated fallback, and `ORCAROUTER_API_KEY` as a second one.)
- **Through your provider key:** the LLM-written parts — `graft build --deep` adds the concept nodes (file summaries + node synthesis) and the per-symbol summaries and cruxes. graft is vendor-neutral: set `GRAFT_PROVIDER` (`openai` for any OpenAI-compatible endpoint, `anthropic` for the native API, or `litellm` / `orcarouter` for a gateway that speaks the OpenAI-compatible format), your `GRAFT_API_KEY`, `GRAFT_MODEL`, and — for the `openai` wire format — `GRAFT_BASE_URL` to point at OpenRouter, Fireworks, Groq, a LiteLLM proxy, a local server, or OpenAI itself. Or pass `--provider/--model/--api-key/--base-url` on the command line. A reasoning-capable model can be told how much hidden reasoning to spend with `GRAFT_REASONING_EFFORT` (or `--reasoning-effort`): `none` | `minimal` | `low` | `medium` | `high`. Leave it unset for the model's own default; set `none` if a server spends the whole token budget reasoning and returns empty summaries. Where a gateway needs that switch — or any other parameter — under a different name or shape, `GRAFT_LLM_EXTRA_BODY` (or `--extra-body`) takes a JSON object merged into the request body: `{"extra_body":{"reasoning_effort":"none"}}` for a LiteLLM proxy, which drops the top-level field during its own param mapping but forwards `extra_body` to the server untouched, or `{"chat_template_kwargs":{"enable_thinking":false}}` for a vLLM server reached directly. (`OPENROUTER_API_KEY` still works as a deprecated fallback, and `ORCAROUTER_API_KEY` as a second one.)
- **Anonymous usage stats** — the only network calls are the LLM requests you configured, a daily npm version check, and one batched usage ping. The ping carries buckets and fixed labels only: never your code, file paths, repo name, symbols, queries, or error messages. [`TELEMETRY.md`](TELEMETRY.md) is the complete list and `graft telemetry debug` prints exactly what your machine would send. Turn it off with `graft telemetry disable`, `DO_NOT_TRACK=1`, or by unchecking the box in `graft init`; it is off in CI and in any build from source.

See [`.env.example`](.env.example) for the full list of settings (model, base URL, graph directory).
Expand Down
12 changes: 11 additions & 1 deletion src/ai/llm/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* gateway's routing, failover, and guardrails behind a named provider instead
* of a bare custom base URL.
*/
import type { ChatModel } from "./types.js";
import type { ChatModel, ExtraBody, ReasoningEffort } from "./types.js";
import { OpenAIChatModel } from "./openai.js";
import { AnthropicChatModel } from "./anthropic.js";
import { LiteLLMChatModel } from "./litellm.js";
Expand All @@ -29,6 +29,10 @@ export interface ChatModelConfig {
baseUrl?: string;
/** Extra default headers for OpenAI-compatible endpoints (e.g. OpenRouter `X-Title`). */
headers?: Record<string, string>;
/** Hidden-reasoning budget for OpenAI-compatible endpoints. Ignored by anthropic. */
reasoningEffort?: ReasoningEffort;
/** Provider-specific request-body parameters for OpenAI-compatible endpoints. Ignored by anthropic. */
extraBody?: ExtraBody;
}

export function createChatModel(cfg: ChatModelConfig): ChatModel {
Expand All @@ -41,20 +45,26 @@ export function createChatModel(cfg: ChatModelConfig): ChatModel {
model: cfg.model,
baseUrl: cfg.baseUrl,
headers: cfg.headers,
reasoningEffort: cfg.reasoningEffort,
extraBody: cfg.extraBody,
});
case "litellm":
return new LiteLLMChatModel({
apiKey: cfg.apiKey,
model: cfg.model,
baseUrl: cfg.baseUrl,
headers: cfg.headers,
reasoningEffort: cfg.reasoningEffort,
extraBody: cfg.extraBody,
});
case "orcarouter":
return new OrcaRouterChatModel({
apiKey: cfg.apiKey,
model: cfg.model,
baseUrl: cfg.baseUrl,
headers: cfg.headers,
reasoningEffort: cfg.reasoningEffort,
extraBody: cfg.extraBody,
});
default: {
const _exhaustive: never = cfg.provider;
Expand Down
59 changes: 58 additions & 1 deletion src/ai/llm/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
import OpenAI from "openai";
import { transportRetries } from "./types.js";
import type { ChatModel, ChatRequest, ChatResponse, Message, ToolCall, ToolSpec, Usage } from "./types.js";
import type { ChatModel, ChatRequest, ChatResponse, ExtraBody, Message, ReasoningEffort, ToolCall, ToolSpec, Usage } from "./types.js";

const PROVIDER = "openai";
/** Synthetic tool used to coerce a plain JSON object out of `{ kind: "json" }`. */
Expand All @@ -25,6 +25,17 @@ export interface OpenAIChatModelOptions {
label?: string;
/** Extra default headers (e.g. OpenRouter's `X-Title`). */
headers?: Record<string, string>;
/**
* Hidden-reasoning budget for reasoning-capable models. Set "none" when a
* server silently spends the whole max_tokens budget on reasoning and returns
* empty content. Omitted by default, leaving the model's own default in force.
*/
reasoningEffort?: ReasoningEffort;
/**
* Provider-specific parameters merged into the request body, for a parameter
* the OpenAI schema has no name for. See {@link ExtraBody}.
*/
extraBody?: ExtraBody;
/** Inject a pre-built client (tests pass a stub; production omits it). */
client?: OpenAI;
}
Expand Down Expand Up @@ -130,14 +141,48 @@ function isRejectedToolsWithReasoning(err: unknown): boolean {
);
}

/**
* Body keys this adapter owns, and the only ones a passthrough may not set.
* `messages`/`tools`/`tool_choice` carry the structured-output coercion the
* caller asked for, `model` is what the graph manifest label is built from, and
* `stream` would change the response shape out from under {@link fromResponse}.
* Everything else is the caller's business — including `temperature` and
* `reasoning_effort`, where overriding graft's value is the point.
*/
const RESERVED_BODY_KEYS = new Set(["model", "messages", "tools", "tool_choice", "stream"]);

/**
* Drop reserved keys once, at construction, rather than per request: a
* passthrough is set once for a whole run, so a silent drop on every call would
* either say nothing or say it thousands of times. Warn and continue instead of
* throwing — the rest of the body is still what the caller's gateway needs.
*/
function sanitizeExtraBody(extra: ExtraBody | undefined, label: string): ExtraBody | undefined {
if (!extra) return undefined;
const kept: ExtraBody = {};
const dropped: string[] = [];
for (const [key, value] of Object.entries(extra)) {
if (RESERVED_BODY_KEYS.has(key)) dropped.push(key);
else kept[key] = value;
}
if (dropped.length > 0) {
console.warn(`graft: ignoring reserved extra-body key(s) for ${label}: ${dropped.join(", ")}`);
}
return Object.keys(kept).length > 0 ? kept : undefined;
}

export class OpenAIChatModel implements ChatModel {
readonly label: string;
private client: OpenAI;
private model: string;
private reasoningEffort?: ReasoningEffort;
private extraBody?: ExtraBody;

constructor(opts: OpenAIChatModelOptions) {
this.model = opts.model;
this.label = opts.label ?? `${PROVIDER}:${opts.model}`;
this.reasoningEffort = opts.reasoningEffort;
this.extraBody = sanitizeExtraBody(opts.extraBody, this.label);
this.client =
opts.client ??
new OpenAI({
Expand All @@ -154,6 +199,12 @@ export class OpenAIChatModel implements ChatModel {
const params: ChatParams = { model: this.model, messages };
if (req.temperature !== undefined) params.temperature = req.temperature;
if (req.maxTokens !== undefined) params.max_tokens = req.maxTokens;
// Sent up front, unlike the reasoning fallback in createChatCompletion: that
// one reacts to a 400, but a server can instead accept the request and return
// 200 with empty content, having spent the whole max_tokens budget on hidden
// reasoning (LM Studio does this). Nothing throws, so no catch-based fallback
// can reach it - the caller has to be able to say "no reasoning" up front.
if (this.reasoningEffort !== undefined) params.reasoning_effort = this.reasoningEffort;

const fmt = req.responseFormat ?? { kind: "text" };
if (fmt.kind === "json") {
Expand All @@ -171,6 +222,12 @@ export class OpenAIChatModel implements ChatModel {
params.tools = tools;
}

// Merged last, so a caller who names a key graft also sets — reasoning_effort
// and temperature are the ones that matter — gets their value on the wire.
// That is the whole point of the escape hatch: the stack in front of the
// model, not this adapter, is what decides which spelling actually works.
if (this.extraBody) Object.assign(params as unknown as Record<string, unknown>, this.extraBody);

const resp = await this.createChatCompletion(params);
return this.fromResponse(resp, fmt.kind);
}
Expand Down
22 changes: 22 additions & 0 deletions src/ai/llm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ export interface ChatModel {
create(req: ChatRequest): Promise<ChatResponse>;
}

/**
* How much hidden reasoning a reasoning-capable model should spend before
* answering. Mirrors the OpenAI-compatible `reasoning_effort` parameter;
* `"none"` disables reasoning entirely.
*/
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high";

/**
* Provider-specific request parameters, merged verbatim into the JSON body of an
* OpenAI-compatible request.
*
* The typed fields cover what the OpenAI schema itself names, but a gateway in
* front of another server routinely needs a parameter that schema has no name
* for — and the same intent can need a different shape on each hop. A LiteLLM
* proxy fronting vLLM drops a top-level `reasoning_effort` during its own param
* mapping yet forwards anything under `extra_body` to the server untouched,
* while a vLLM server reached directly wants `chat_template_kwargs:
* { enable_thinking: false }` instead. Rather than model each gateway's quirks,
* let the caller state the body their own stack needs.
*/
export type ExtraBody = Record<string, unknown>;

/**
* How many times the transport retries a failed request before the error reaches
* the caller. Both SDKs retry only what is worth retrying (429 and 5xx, honouring
Expand Down
54 changes: 54 additions & 0 deletions src/ai/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Synthesizer } from "./synthesize.js";
import type { CruxSummarizer } from "./crux.js";
import type { ChatModel } from "./llm/types.js";
import type { ProviderKind } from "./llm/factory.js";
import type { ExtraBody, ReasoningEffort } from "./llm/types.js";

/**
* User-facing configuration. Anything omitted falls back to environment
Expand All @@ -26,6 +27,20 @@ export interface EngineConfig {
model?: string;
/** Base URL for OpenAI-compatible endpoints. Env: GRAFT_BASE_URL. */
baseUrl?: string;
/**
* Hidden-reasoning budget for reasoning-capable models, on OpenAI-compatible
* providers. Env: GRAFT_REASONING_EFFORT. Unset leaves the model's own default.
* Set "none" against a server that spends the whole token budget on reasoning
* and returns empty content.
*/
reasoningEffort?: ReasoningEffort;
/**
* Provider-specific parameters merged into the request body on
* OpenAI-compatible providers. Env: GRAFT_LLM_EXTRA_BODY, as a JSON object.
* Accepts a JSON string too, so the env var and `--extra-body` parse in one
* place. See {@link ExtraBody} for why the typed fields are not always enough.
*/
extraBody?: ExtraBody | string;

// --- advanced: bring your own components ---
/** Override the whole transport (skips provider/apiKey/baseUrl). */
Expand All @@ -45,6 +60,8 @@ export interface ResolvedConfig {
apiKey?: string;
model: string;
baseUrl?: string;
reasoningEffort?: ReasoningEffort;
extraBody?: ExtraBody;
headers?: Record<string, string>;
/** True when the key came from the deprecated OPENROUTER_* fallback. */
usedLegacyEnv: boolean;
Expand Down Expand Up @@ -72,6 +89,33 @@ export const DEFAULTS = {
model: DEFAULT_MODELS.openai,
} as const;

/**
* Parse an extra-body value that may arrive as an object (a programmatic caller)
* or as JSON text (the env var and the CLI flag).
*
* Throws rather than ignoring bad input: a passthrough exists precisely because
* the request fails without it, so silently dropping a malformed one would send
* the very request the user was trying to avoid — and they would see whatever
* their gateway does with it, not what they got wrong here.
*/
export function parseExtraBody(value: ExtraBody | string | undefined, source: string): ExtraBody | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") return value;
const text = value.trim();
if (text === "") return undefined;

let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
throw new Error(`${source} must be valid JSON: ${(err as Error).message}`);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${source} must be a JSON object, e.g. '{"extra_body":{"reasoning_effort":"none"}}'`);
}
return parsed as ExtraBody;
}

/** Merge user config with environment variables and defaults. */
export function resolveConfig(config: EngineConfig = {}): ResolvedConfig {
const env = process.env;
Expand Down Expand Up @@ -101,12 +145,22 @@ export function resolveConfig(config: EngineConfig = {}): ResolvedConfig {
? { "X-Title": "graft" }
: undefined;

const reasoningEffort =
config.reasoningEffort ?? (env.GRAFT_REASONING_EFFORT as ReasoningEffort | undefined);

const extraBody =
config.extraBody !== undefined
? parseExtraBody(config.extraBody, "extraBody")
: parseExtraBody(env.GRAFT_LLM_EXTRA_BODY, "GRAFT_LLM_EXTRA_BODY");

return {
contextDir: config.contextDir ?? env.GRAFT_DIR,
provider,
apiKey,
model,
baseUrl,
reasoningEffort,
extraBody,
headers,
usedLegacyEnv,
chatModel: config.chatModel,
Expand Down
1 change: 1 addition & 0 deletions src/blast/name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export async function nameReport(
namer = new ChatNamer(createChatModel({
provider: cfg.provider, apiKey: cfg.apiKey, model: cfg.model,
baseUrl: cfg.baseUrl, headers: cfg.headers,
reasoningEffort: cfg.reasoningEffort, extraBody: cfg.extraBody,
}));
}

Expand Down
14 changes: 13 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,24 @@ program
.option("--provider <name>", "LLM wire format: openai | anthropic | litellm | orcarouter (env GRAFT_PROVIDER)")
.option("--model <id>", "model id for the LLM pass (env GRAFT_MODEL)")
.option("--api-key <key>", "provider API key (env GRAFT_API_KEY)")
.option("--base-url <url>", "OpenAI-compatible endpoint URL (env GRAFT_BASE_URL)");
.option("--base-url <url>", "OpenAI-compatible endpoint URL (env GRAFT_BASE_URL)")
.option(
"--reasoning-effort <level>",
"hidden-reasoning budget: none | minimal | low | medium | high (env GRAFT_REASONING_EFFORT)",
)
.option(
"--extra-body <json>",
"JSON object merged into the LLM request body, for gateway-specific params (env GRAFT_LLM_EXTRA_BODY)",
);

interface GlobalOpts {
dir?: string;
provider?: string;
model?: string;
apiKey?: string;
baseUrl?: string;
reasoningEffort?: string;
extraBody?: string;
}

/** Config drawn from the global CLI flags (env + defaults fill the rest). */
Expand All @@ -117,6 +127,8 @@ function cliConfig(): EngineConfig {
model: o.model,
apiKey: o.apiKey,
baseUrl: o.baseUrl,
reasoningEffort: o.reasoningEffort as EngineConfig["reasoningEffort"],
extraBody: o.extraBody,
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export class Graft {
model: this.cfg.model,
baseUrl: this.cfg.baseUrl,
headers: this.cfg.headers,
reasoningEffort: this.cfg.reasoningEffort,
extraBody: this.cfg.extraBody,
});
return this._chatModel;
}
Expand Down
Loading
Loading