diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index b632256..7f2c51a 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -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'); @@ -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 @@ -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) => { diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 9eff512..2a8291e 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -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 }] })); @@ -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 { diff --git a/extensions/levelcode-ai/providers/catalog.js b/extensions/levelcode-ai/providers/catalog.js index eda0c98..a126433 100644 --- a/extensions/levelcode-ai/providers/catalog.js +++ b/extensions/levelcode-ai/providers/catalog.js @@ -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 @@ -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) { @@ -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 }; diff --git a/extensions/levelcode-ai/providers/index.js b/extensions/levelcode-ai/providers/index.js index 7f799df..08e8495 100644 --- a/extensions/levelcode-ai/providers/index.js +++ b/extensions/levelcode-ai/providers/index.js @@ -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 }); } @@ -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 }); } @@ -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 }); } diff --git a/extensions/levelcode-ai/providers/openaiCompat.js b/extensions/levelcode-ai/providers/openaiCompat.js index 9c8f818..048bb6c 100644 --- a/extensions/levelcode-ai/providers/openaiCompat.js +++ b/extensions/levelcode-ai/providers/openaiCompat.js @@ -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 @@ -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; } @@ -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 diff --git a/extensions/levelcode-ai/test/agentReasoningBudget.test.js b/extensions/levelcode-ai/test/agentReasoningBudget.test.js new file mode 100644 index 0000000..0f02a42 --- /dev/null +++ b/extensions/levelcode-ai/test/agentReasoningBudget.test.js @@ -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); }); diff --git a/extensions/levelcode-ai/test/catalog.test.js b/extensions/levelcode-ai/test/catalog.test.js index a68019a..249afe8 100644 --- a/extensions/levelcode-ai/test/catalog.test.js +++ b/extensions/levelcode-ai/test/catalog.test.js @@ -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); diff --git a/extensions/levelcode-ai/test/compactReasoningHost.test.js b/extensions/levelcode-ai/test/compactReasoningHost.test.js new file mode 100644 index 0000000..88ff30d --- /dev/null +++ b/extensions/levelcode-ai/test/compactReasoningHost.test.js @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Compaction on a reasoning model — run: node test/compactReasoningHost.test.js + * + * Compact summarizes the head of the agent's transcript on the active model, with 1,500 tokens of + * output. A model that always reasons (Gemini 4 Argon) thinks out of those same tokens, and could + * spend them all before writing a word: an empty summary, and Compact reports "empty". A reasoning + * model now gets 8,192 tokens and, where its row names an effort, is asked for a low one. Every other + * model keeps 1,500 and sends no effort. + * + * compactAgentMemory is sliced out of extension.js and run with stand-ins around it. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const catalog = require('../providers/catalog'); +const { findCompactionCut, estimateMsgTokens } = require('../agentMemory'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'extension.js'), 'utf8'); + +/** Slice a top-level `[async] function (…) {…}`, skipping braces in strings and comments. */ +function extract(name) { + let start = src.indexOf('function ' + name + '('); + assert.ok(start >= 0, 'extension.js no longer defines ' + name + '()'); + const open = src.indexOf('{', start); + if (src.slice(start - 6, start) === 'async ') { start -= 6; } + let depth = 0, str = '', comment = ''; + for (let i = open; i < src.length; i++) { + const ch = src[i], next = src[i + 1]; + if (comment === 'line') { if (ch === '\n') { comment = ''; } continue; } + if (comment === 'block') { if (ch === '*' && next === '/') { comment = ''; i++; } continue; } + if (str) { if (ch === '\\') { i++; } else if (ch === str) { str = ''; } continue; } + if (ch === '/' && next === '/') { comment = 'line'; i++; continue; } + if (ch === '/' && next === '*') { comment = 'block'; i++; continue; } + if (ch === '"' || ch === "'" || ch === '`') { str = ch; continue; } + if (ch === '{') { depth++; } + else if (ch === '}' && --depth === 0) { return src.slice(start, i + 1); } + } + throw new Error('no matching closing brace found for ' + name + '()'); +} + +// eslint-disable-next-line no-new-func +const makeHost = new Function('env', [ + "'use strict';", + 'const { providers, catalog, findCompactionCut, estimateMsgTokens, prepProviderRequest } = env;', + "const COMPACT_SYSTEM = 'system', COMPACT_INSTRUCTIONS = 'Summarize:\\n';", + 'const dbg = () => {}, meterModel = () => env.model, serializeMsgForSummary = (m) => JSON.stringify(m);', + 'const authRetry = (req, fn) => fn(req);', + 'const checkpoints = [], diagrams = { stubsFor: () => [] };', + 'let abort = null, agentMessages = [], diagramsStubbed = false;', + extract('compactAgentMemory'), + 'return { compactAgentMemory, setAgentMessages: (m) => { agentMessages = m; } };' +].join('\n')); + +/** A transcript long enough to compact: alternating goals and answers. */ +function transcript(len) { + const out = []; + for (let i = 0; i < len; i++) { out.push(i % 2 ? { role: 'assistant', content: 'answer ' + i } : { role: 'user', content: 'goal ' + i }); } + return out; +} + +let n = 0; +async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name); } + +/** Compact once on `model`; returns what the summary request asked for. */ +async function compactOn(model) { + /** @type {any[]} */ + const asked = []; + const providers = { complete: async (o) => { asked.push({ model: o.model, maxTokens: o.maxTokens, reasoningEffort: o.reasoningEffort }); return 'A briefing.'; } }; + const prepProviderRequest = async () => ({ ok: true, providerId: 'openrouter', apiKey: 'k', model }); + const host = makeHost({ providers, catalog, findCompactionCut, estimateMsgTokens, prepProviderRequest, model }); + host.setAgentMessages(transcript(14)); + const r = await host.compactAgentMemory(); + assert.strictEqual(r.ok, true, 'compacted: ' + JSON.stringify(r)); + assert.strictEqual(asked.length, 1); + return asked[0]; +} + +(async () => { + await test('Gemini 4 Argon summarizes with 8,192 tokens and a low effort', async () => { + assert.deepStrictEqual(await compactOn('google/gemini-4-argon'), { model: 'google/gemini-4-argon', maxTokens: 8192, reasoningEffort: 'low' }); + }); + await test('other reasoning models get the room, but no effort they were not checked for', async () => { + assert.deepStrictEqual(await compactOn('o3-mini'), { model: 'o3-mini', maxTokens: 8192, reasoningEffort: null }); + assert.deepStrictEqual(await compactOn('gemini-4-argon'), { model: 'gemini-4-argon', maxTokens: 8192, reasoningEffort: null }); + }); + await test('a model that does not reason keeps 1,500 tokens and sends no effort', async () => { + assert.deepStrictEqual(await compactOn('moonshotai/kimi-k2.7-code'), { model: 'moonshotai/kimi-k2.7-code', maxTokens: 1500, reasoningEffort: null }); + assert.deepStrictEqual(await compactOn('anthropic/claude-opus-5'), { model: 'anthropic/claude-opus-5', maxTokens: 1500, reasoningEffort: null }); + }); + console.log('\ncompactReasoningHost: ' + n + ' tests passed.'); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/extensions/levelcode-ai/test/providers.test.js b/extensions/levelcode-ai/test/providers.test.js index f272891..51b23e4 100644 --- a/extensions/levelcode-ai/test/providers.test.js +++ b/extensions/levelcode-ai/test/providers.test.js @@ -44,6 +44,16 @@ test('isReasoningModel: matches o1/o3/o4 (incl. openrouter-prefixed), NOT gpt-4o for (const m of ['o1', 'o1-preview', 'o3-mini', 'o4-mini', 'openai/o3-mini']) { assert.strictEqual(oc.isReasoningModel(m), true, m); } for (const m of ['gpt-4o', 'gpt-4o-mini', 'openai/gpt-4o', 'codestral-latest', 'grok-2-latest', 'meta-llama/llama-3.3-70b-instruct', 'deepseek-chat']) { assert.strictEqual(oc.isReasoningModel(m), false, m); } }); +test('buildChatBody: a reasoning effort becomes reasoning.effort; without one, no reasoning field is sent', () => { + const g = oc.buildChatBody({ model: 'google/gemini-4-argon', messages: [], maxTokens: 32768, reasoningEffort: 'medium' }); + assert.deepStrictEqual(g.reasoning, { effort: 'medium' }); + // Argon is not an o-series model: it keeps max_tokens, the field OpenRouter lists for it. + assert.strictEqual(g.max_tokens, 32768); + assert.ok(!('max_completion_tokens' in g)); + for (const e of [undefined, null, '']) { + assert.ok(!('reasoning' in oc.buildChatBody({ model: 'm', messages: [], reasoningEffort: e })), String(e)); + } +}); test('buildChatBody: reasoning model → max_completion_tokens (no max_tokens) and temperature dropped', () => { const o = oc.buildChatBody({ model: 'o3-mini', messages: [], maxTokens: 8192, temperature: 0.2 }); assert.strictEqual(o.max_completion_tokens, 8192); @@ -226,6 +236,22 @@ async function testAsync(name, fn) { await fn(); n++; console.log(' ok - ' + na assert.strictEqual(calls, 1, 'AbortError propagates immediately — never retried'); }); + // The effort has to survive index.js, which copies the options it forwards field by field. + await testAsync('chat, one-shot and agent turns all carry reasoningEffort into the request body', async () => { + const P = require('../providers/index'); + const bodies = []; + global.fetch = async (url, init) => { bodies.push(JSON.parse(init.body)); return fakeRes(400, '{"error":{"message":"stop here"}}'); }; + const base = { providerId: 'openrouter', apiKey: 'k', model: 'google/gemini-4-argon', maxTokens: 64, system: 's', messages: [{ role: 'user', content: 'hi' }], reasoningEffort: 'medium' }; + await assert.rejects(P.streamChat(Object.assign({}, base, { onDelta() {} })), /stop here/); + await assert.rejects(P.complete(base), /stop here/); + await assert.rejects(P.streamAgentTurn(Object.assign({}, base, { tools: [] })), /stop here/); + assert.strictEqual(bodies.length, 3); + for (const b of bodies) { assert.deepStrictEqual(b.reasoning, { effort: 'medium' }); assert.strictEqual(b.model, 'google/gemini-4-argon'); } + bodies.length = 0; + await assert.rejects(P.streamAgentTurn(Object.assign({}, base, { tools: [], reasoningEffort: null })), /stop here/); + assert.ok(!('reasoning' in bodies[0]), 'no effort, no field'); + }); + global.fetch = savedFetch; console.log('\nproviders: ' + n + ' tests passed.'); })().catch((e) => { console.error(e); process.exit(1); }); diff --git a/extensions/levelcode-ai/test/sessionExpiredHost.test.js b/extensions/levelcode-ai/test/sessionExpiredHost.test.js index b0f3834..3720152 100644 --- a/extensions/levelcode-ai/test/sessionExpiredHost.test.js +++ b/extensions/levelcode-ai/test/sessionExpiredHost.test.js @@ -102,7 +102,7 @@ const FUNCTIONS = [ // eslint-disable-next-line no-new-func const makeHost = new Function('env', [ "'use strict';", - 'const { vscode, session, providers, resolveGateway, fetch, ctx, dbg, aiConfig, refreshCloudProfile, fetchCloudRoster, sendConfigToWebview,', + 'const { vscode, session, providers, catalog, resolveGateway, fetch, ctx, dbg, aiConfig, refreshCloudProfile, fetchCloudRoster, sendConfigToWebview,', ' gatewayModel, maxOutputTokens, currentProviderId, baseUrlFor, getProviderKey, activeModel, storeImages, agentFlow,', ' listWorkspaceFiles, workspaceMapBlock, activeFileBlock, contextFileBlocks, gatherAutoContext, labelImages, withImages,', ' sealLiveSession, resetConversationState, postContextFiles, postMemoryDigest, sessionsManager, currentContextLimit,', @@ -258,6 +258,8 @@ function boot(over) { } }; }, + // The real capability table: the chat asks it which reasoning effort, if any, a model takes. + catalog: require('../providers/catalog'), providers: { getProvider: (id) => ({ id, label: 'Claude' }), isInsecureCustomUrl: () => false, @@ -271,7 +273,7 @@ function boot(over) { aiConfig: () => vscode.workspace.getConfiguration('levelcode.ai'), dbg: () => { }, refreshCloudProfile: async () => { }, fetchCloudRoster: () => { }, sendConfigToWebview: () => { }, gatewayModel: () => 'cloud-model', maxOutputTokens: () => 4096, currentProviderId: () => 'claude', - baseUrlFor: () => '', activeModel: () => 'claude-model', storeImages: () => [], agentFlow: async () => { }, + baseUrlFor: () => '', activeModel: () => t.activeModelId || 'claude-model', storeImages: () => [], agentFlow: async () => { }, listWorkspaceFiles: async () => [], workspaceMapBlock: () => '', activeFileBlock: () => '', contextFileBlocks: async () => [], gatherAutoContext: async () => ({ blocks: [], names: [] }), labelImages: (b) => b, withImages: (m) => m, @@ -488,6 +490,16 @@ async function test(name, fn) { await fn(); n++; console.log(' ok - ' + name); assert.ok(!t.posted.some((m) => m.code === 'session_expired' || m.type === 'sessionExpired'), 'no sign-in card'); }); + await test('chat: a model whose catalog row names a reasoning effort sends it, and any other sends none', async () => { + const t = boot({ byokKey: 'sk-own' }); + await t.host.handleSend('hello'); + assert.strictEqual(t.calls.stream[0].reasoningEffort, null, 'a model without an effort'); + t.activeModelId = 'google/gemini-4-argon'; + await t.host.handleSend('and again'); + assert.strictEqual(t.calls.stream[1].model, 'google/gemini-4-argon'); + assert.strictEqual(t.calls.stream[1].reasoningEffort, 'medium'); + }); + await test('BYOK: no account and no key is "No API key set for ", not a session card', async () => { const t = boot({}); const req = await t.host.prepProviderRequest({ prompt: true });