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
8 changes: 7 additions & 1 deletion extensions/levelcode-ai/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const cp = require('child_process');
const os = require('os'); // MCP servers need a cwd even when no folder is open
const host = require('./host'); // the machine we run on: workspace I/O is async here, and capabilities differ (browser)
const providers = require('./providers/index');
const catalog = require('./providers/catalog');
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify');
const { classifyCommand, dangerLabel } = require('./commandSafety');
const { commitShellChanges } = require('./shellCommit');
Expand Down Expand Up @@ -1142,7 +1143,11 @@ async function runAgent(ctx) {
// full API call whose input grows with the (never-truncated) transcript, so an adversarial or
// runaway model that keeps hitting the cap could otherwise rack up unbounded, user-billed calls.
// Budget = a generous multiple of the per-turn cap; once exceeded we stop continuing and hand back.
const perTurnMax = Math.max(1024, Math.min(32768, ctx.maxTokens || 8192));
// A reasoning model spends its hidden reasoning out of the same max_tokens, so the 8,192 default can
// leave nothing for the answer (Gemini 4 Argon reasons on every turn). Such models get the ceiling,
// 32,768, which is also the most a paid request may ask of the LevelCode gateway.
const reasons = !!catalog.modelCaps(ctx.model).reasoning;
const perTurnMax = Math.max(1024, Math.min(32768, reasons ? 32768 : (ctx.maxTokens || 8192)));
const OUTPUT_TOKEN_BUDGET = perTurnMax * 8;
let cumulativeOutputTokens = 0;
// [LevelCode] Gateway turns report real money: this run's cost + the wallet balance, both RETAIL
Expand Down Expand Up @@ -1219,6 +1224,7 @@ async function runAgent(ctx) {
const turnOpts = {
providerId: ctx.providerId, baseURL: ctx.baseURL, label: ctx.label,
apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system,
reasoningEffort: catalog.reasoningEffortFor(ctx.model, 'agent'),
messages, tools: tools, signal: ctx.signal,
onText: (t) => { streamed = true; textChars += t.length; ctx.post({ type: 'agentDelta', text: t }); },
onToolStart: (name, id) => {
Expand Down
6 changes: 5 additions & 1 deletion extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -2159,7 +2159,10 @@ async function compactAgentMemory() {
// than Compact failing on a 401 that the next chat message would have recovered from.
summary = await authRetry(req, (r) => providers.complete({
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
model: r.model, maxTokens: 1500,
// A reasoning model thinks out of the same budget, and 1,500 tokens of thought leaves an empty
// summary. It gets room for both, and is asked to think little.
model: r.model, maxTokens: catalog.modelCaps(r.model).reasoning ? 8192 : 1500,
reasoningEffort: catalog.reasoningEffortFor(r.model, 'compact'),
system: COMPACT_SYSTEM,
messages: [{ role: 'user', content: COMPACT_INSTRUCTIONS + flat }]
}));
Expand Down Expand Up @@ -2586,6 +2589,7 @@ async function handleSend(text, images) {
const doStream = (r) => providers.streamChat({
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
model: r.model, maxTokens: r.maxTokens, system: SYSTEM_PROMPT,
reasoningEffort: catalog.reasoningEffortFor(r.model, 'chat'),
messages: withImages(conversation), signal: abort.signal, onDelta
});
try {
Expand Down
23 changes: 23 additions & 0 deletions extensions/levelcode-ai/providers/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ const CAPS = {
// real one is 1.05M (OpenRouter models API, 2026-09-06) and it reads images. Without this row the
// gateway picker offers it while the composer refuses attachments and the meter sizes it wrong.
'gpt-6-astra': { context: 1050000, tools: true, vision: true },
// Gemini 4 Argon (2026-10-13): a 1,048,576 window, image and file input, and reasoning that is always
// on. The `gemini` heuristic below gave it 1,000,000 and no reasoning flag. No `fast`: it must never
// become the ghost-text model. `effort` is sent as `reasoning: { effort }` (see reasoningEffortFor),
// and only under the vendor-prefixed id, which is how OpenRouter and the LevelCode gateway name it;
// both take that field for this model. A bare id may reach some other endpoint, so it asks for none.
'google/gemini-4-argon': { context: 1048576, tools: true, vision: true, reasoning: true, effort: 'medium' },
'gemini-4-argon': { context: 1048576, tools: true, vision: true, reasoning: true },
'o3-mini': { context: 200000, tools: true, reasoning: true },
'o1': { context: 200000, tools: true, reasoning: true },
'o1-mini': { context: 128000, tools: false, reasoning: true }, // o1-mini has no function calling
Expand Down Expand Up @@ -191,6 +198,21 @@ function fastCompletionModel(providerId) {
return (p && FAST_COMPLETION[p.id]) || null;
}

/**
* The `reasoning.effort` to ask of a model, or null to send none (the provider's default applies).
* Only a model whose CAPS row names an effort gets one. `purpose` 'compact' is for the summaries the
* editor writes for itself, which need little thought, so it asks for 'low'; chat and the agent get the
* row's own effort.
* @param {string} modelId
* @param {'chat'|'agent'|'compact'} purpose
* @returns {string|null}
*/
function reasoningEffortFor(modelId, purpose) {
const effort = modelCaps(modelId).effort;
if (!effort) { return null; }
return purpose === 'compact' ? 'low' : effort;
}

/** Format a caps object as a compact QuickPick detail string, e.g. "128k ctx · tools · vision". */
function fmtContext(n) { return n >= 1000 ? Math.round(n / 1000) + 'k ctx' : n + ' ctx'; }
function describeCaps(c) {
Expand Down Expand Up @@ -296,6 +318,7 @@ async function getModelChoices(providerId, opts) {
module.exports = {
CAPS, modelCaps, baseName, heuristicCaps,
supportsToolsForModel, diagramSupportForModel, supportsVisionForModel, contextWindowFor, fastCompletionModel,
reasoningEffortFor,
describeCaps, describeModel,
mapOpenRouterModels, mapModelIds, fetchModels, getModelChoices
};
7 changes: 4 additions & 3 deletions extensions/levelcode-ai/providers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ async function streamChat(o) {
return openai.streamOpenAI({
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages,
signal: o.signal, onDelta: o.onDelta, onRetry: o.onRetry
signal: o.signal, onDelta: o.onDelta, onRetry: o.onRetry, reasoningEffort: o.reasoningEffort
});
}

Expand All @@ -181,7 +181,7 @@ async function complete(o) {
return openai.completeOpenAI({
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages,
stop: o.stop, signal: o.signal, onRetry: o.onRetry
stop: o.stop, signal: o.signal, onRetry: o.onRetry, reasoningEffort: o.reasoningEffort
});
}

Expand Down Expand Up @@ -216,7 +216,8 @@ async function streamAgentTurn(o) {
return openai.streamOpenAIAgentTurn({
baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,
model: o.model, maxTokens: o.maxTokens, system: o.system, messages: o.messages, tools: o.tools,
signal: o.signal, onText: o.onText, onToolStart: o.onToolStart, onToolInput: o.onToolInput, onRetry: o.onRetry
signal: o.signal, onText: o.onText, onToolStart: o.onToolStart, onToolInput: o.onToolInput, onRetry: o.onRetry,
reasoningEffort: o.reasoningEffort
});
}

Expand Down
8 changes: 5 additions & 3 deletions extensions/levelcode-ai/providers/openaiCompat.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ function isReasoningModel(model) {
* Build the /v1/chat/completions request body. Pure — unit-testable.
* OpenAI has no top-level `system` field, so a `system` string becomes a leading
* {role:'system'} message (exactly what the old Ollama path did). The output-token cap is sent as
* `max_completion_tokens` for reasoning models and `max_tokens` otherwise.
* `max_completion_tokens` for reasoning models and `max_tokens` otherwise. A `reasoningEffort` becomes
* OpenRouter's `reasoning: { effort }`; callers pass one only for a model that takes it (catalog.js).
* @param {{model:string, system?:string, messages:any[], maxTokens?:number,
* stream?:boolean, stop?:string[], temperature?:number}} opts
* stream?:boolean, stop?:string[], temperature?:number, reasoningEffort?:string|null}} opts
*/
function buildChatBody(opts) {
const msgs = opts.system
Expand All @@ -62,6 +63,7 @@ function buildChatBody(opts) {
if (opts.stream) { body.stream = true; }
if (opts.stop && opts.stop.length) { body.stop = opts.stop; }
if (opts.temperature != null && !reasoning) { body.temperature = opts.temperature; }
if (opts.reasoningEffort) { body.reasoning = { effort: opts.reasoningEffort }; }
return body;
}

Expand Down Expand Up @@ -262,7 +264,7 @@ async function streamOpenAIAgentTurn(opts) {
// the field, so we gate it strictly on the model id. cached_tokens is still read for ALL providers below.
const messages = translate.toOpenAIMessages(opts.system, opts.messages, { cache: isAnthropicFamily(opts.model) });
const tools = translate.toOpenAITools(opts.tools);
const body = buildChatBody({ model: opts.model, messages, maxTokens: opts.maxTokens, stream: true });
const body = buildChatBody({ model: opts.model, messages, maxTokens: opts.maxTokens, stream: true, reasoningEffort: opts.reasoningEffort });
if (tools) { body.tools = tools; }
// Ask for a final usage chunk (choices:[] + usage) so the agent's context meter isn't stuck at 0
// on OpenAI-shaped providers — they omit usage from streams unless include_usage is set. Mainstream
Expand Down
76 changes: 76 additions & 0 deletions extensions/levelcode-ai/test/agentReasoningBudget.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*---------------------------------------------------------------------------------------------
* A reasoning model gets the whole per-turn output ceiling, and its effort — run: node test/agentReasoningBudget.test.js
*
* Gemini 4 Argon reasons on every turn, and its reasoning comes out of the same max_tokens as the answer.
* At the 8,192 default a hard turn could spend it all thinking and stop with nothing said. A model whose
* caps say it reasons now asks for 32,768 (the ceiling, and the LevelCode gateway's own limit for a paid
* request), and a model whose row names an effort sends it. Everything else keeps the user's setting.
*
* This drives the real agent loop with a stand-in for the provider and reads what each turn asked for.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const Module = require('module');

const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-reasoning-budget-'));
const vscodeMock = { workspace: { workspaceFolders: [{ uri: { fsPath: root }, name: 'app' }] } };
const origLoad = Module._load;
// @ts-ignore
Module._load = function (request, parent, isMain) {
if (request === 'vscode') { return vscodeMock; }
return origLoad.call(this, request, parent, isMain);
};
const providers = require('../providers/index');
/** @type {any[]} */
const asked = [];
providers.streamAgentTurn = async (o) => {
asked.push({ model: o.model, maxTokens: o.maxTokens, reasoningEffort: o.reasoningEffort });
return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'Done: nothing to change.' }] };
};
const { runAgent } = require('../agent');

let n = 0;
async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name); }

/** One single-turn run on `model` with the user's `maxTokens` setting; returns what that turn asked for. */
async function turnFor(model, maxTokens) {
asked.length = 0;
await runAgent({
providerId: 'openrouter', model, maxTokens, messages: [{ role: 'user', content: 'go' }], maxSteps: 2,
post() {}, dbg() {}, approve: async () => true, commandRuns: new Map(), commandStops: new Map(), signal: new AbortController().signal,
});
assert.strictEqual(asked.length, 1, 'one turn');
return asked[0];
}

(async () => {
try {
await test('Gemini 4 Argon through OpenRouter or the gateway: 32,768 per turn and medium effort', async () => {
assert.deepStrictEqual(await turnFor('google/gemini-4-argon', 8192), { model: 'google/gemini-4-argon', maxTokens: 32768, reasoningEffort: 'medium' });
});
await test('the bare Argon id also gets the ceiling, but asks for no effort', async () => {
const t = await turnFor('gemini-4-argon', 8192);
assert.strictEqual(t.maxTokens, 32768);
assert.strictEqual(t.reasoningEffort, null);
});
await test('other reasoning models get the ceiling too (their reasoning shares the budget the same way)', async () => {
assert.strictEqual((await turnFor('moonshotai/kimi-k3', 8192)).maxTokens, 32768);
assert.strictEqual((await turnFor('o3-mini', 4096)).maxTokens, 32768);
});
await test('a model that does not reason keeps the user\'s setting, its clamp, and sends no effort', async () => {
assert.deepStrictEqual(await turnFor('moonshotai/kimi-k2.7-code', 8192), { model: 'moonshotai/kimi-k2.7-code', maxTokens: 8192, reasoningEffort: null });
assert.strictEqual((await turnFor('moonshotai/kimi-k2.7-code', 100000)).maxTokens, 32768, 'still clamped to 32,768');
assert.strictEqual((await turnFor('moonshotai/kimi-k2.7-code', 10)).maxTokens, 1024, 'still at least 1,024');
assert.strictEqual((await turnFor('anthropic/claude-opus-5', 0)).maxTokens, 8192, 'the default when nothing is set');
});
console.log('\nagentReasoningBudget: ' + n + ' tests passed.');
} finally {
Module._load = origLoad;
fs.rmSync(root, { recursive: true, force: true });
}
})().catch((e) => { console.error(e); process.exit(1); });
29 changes: 29 additions & 0 deletions extensions/levelcode-ai/test/catalog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@ test('modelCaps: gpt-6-astra is an explicit row, not the gpt-4/5 heuristic', ()
assert.strictEqual(C.supportsVisionForModel('openai', 'gpt-6-astra'), true);
assert.strictEqual(C.contextWindowFor('openai', 'gpt-6-astra'), 1050000);
});
// Gemini 4 Argon reaches the picker from the gateway roster and from OpenRouter (BYOK). Its window is
// 1,048,576, not the gemini heuristic's 1,000,000, and it reasons on every turn.
test('modelCaps: gemini-4-argon is an explicit row under both ids; only the OpenRouter id asks for an effort', () => {
const slug = C.modelCaps('google/gemini-4-argon');
const bare = C.modelCaps('gemini-4-argon');
for (const c of [slug, bare]) {
assert.strictEqual(c.context, 1048576);
assert.strictEqual(c.tools, true);
assert.strictEqual(c.vision, true);
assert.strictEqual(c.reasoning, true);
assert.ok(!c.fast, 'never the ghost-text model');
}
assert.strictEqual(slug.effort, 'medium');
assert.ok(!('effort' in bare), 'a bare id may reach an endpoint that was not checked for the field');
assert.strictEqual(C.contextWindowFor('openrouter', 'google/gemini-4-argon'), 1048576);
assert.strictEqual(C.supportsVisionForModel('openai', 'google/gemini-4-argon'), true); // the gateway's provider id
assert.strictEqual(C.supportsToolsForModel('openrouter', 'google/gemini-4-argon'), true);
assert.strictEqual(C.supportsToolsForModel('openai', 'google/gemini-4-argon'), true);
assert.strictEqual(C.describeModel('google/gemini-4-argon'), '1049k ctx · reasoning · tools · vision');
});
test('reasoningEffortFor: medium for chat and the agent, low for compaction, none for a model without one', () => {
assert.strictEqual(C.reasoningEffortFor('google/gemini-4-argon', 'chat'), 'medium');
assert.strictEqual(C.reasoningEffortFor('google/gemini-4-argon', 'agent'), 'medium');
assert.strictEqual(C.reasoningEffortFor('google/gemini-4-argon', 'compact'), 'low');
for (const id of ['gemini-4-argon', 'moonshotai/kimi-k2.7-code', 'moonshotai/kimi-k3', 'o3-mini', 'anthropic/claude-opus-5', '']) {
assert.strictEqual(C.reasoningEffortFor(id, 'agent'), null, id);
assert.strictEqual(C.reasoningEffortFor(id, 'compact'), null, id);
}
});
test('modelCaps: Fable 5 / 5.1 carry the 1M window under every id they arrive as', () => {
for (const id of ['anthropic/claude-fable-5', 'anthropic/claude-fable-5.1', 'claude-fable-5-1']) {
assert.deepStrictEqual(C.modelCaps(id), { context: 1000000, tools: true, vision: true, caching: true }, id);
Expand Down
Loading
Loading