@spec-wave/cli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,12 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
- import { readFileSync, existsSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { CONFIG_FILE, getProvider, DEFAULT_PROVIDER } from '../config.mjs';
2
+ import { readFileSync } from 'node:fs';
3
+ import {
4
+ getProvider, DEFAULT_PROVIDER, MODEL_LABEL_PREFIX,
5
+ DEFAULT_MAX_CRITIQUE_ATTEMPTS, labelNames,
6
+ } from '../config.mjs';
5
7
  import { lintLanguage } from './output-lint.mjs';
6
8
  import { computeCost } from './usage-report.mjs';
9
+ import { findConfigPath } from './project-root.mjs';
7
10
 
8
11
  // Teto de saída padrão. O valor anterior (8192) truncava specs reais: um
9
12
  // documento de ~20 KB em pt-BR já custa ~6k tokens, e modelos com raciocínio
@@ -12,44 +15,99 @@ import { computeCost } from './usage-report.mjs';
12
15
  // branco. Ajustável por `ai.maxTokens` / `ai.maxTokensByAction`.
13
16
  export const DEFAULT_MAX_TOKENS = 32768;
14
17
 
18
+ // Teto padrão POR AÇÃO, aplicado abaixo de `ai.maxTokens` na precedência. A
19
+ // crítica devolve uma lista JSON de findings, muito menor que um spec/plan —
20
+ // 8192 é folgado para ela. Antes esse valor era passado hard-coded pelo
21
+ // critique.mjs, o que sobrepunha `ai.maxTokensByAction.critique` e tornava o
22
+ // knob morto; aqui ele é só um DEFAULT, então a configuração volta a valer.
23
+ export const MAX_TOKENS_BY_ACTION = { critique: 8192 };
24
+
25
+ /**
26
+ * Resolve o alias de modelo vindo de label (função PURA).
27
+ *
28
+ * A label `spec-wave:model:<apelido>` existe para reprocessar um caso difícil num
29
+ * modelo mais forte sem editar o .spec-wave.json e commitar — a decisão vale só
30
+ * para aquela issue. Duas labels na mesma issue é ambiguidade: nenhuma vale, não
31
+ * se sorteia.
32
+ *
33
+ * @param {Array<string|{name:string}>} [labels] labels da issue
34
+ * @param {object} [modelAliases] bloco `ai.modelAliases` do .spec-wave.json
35
+ * @returns {null|{ alias: string, model: string|null, error: null|'desconhecido'|'ambíguo' }}
36
+ */
37
+ export function resolveModelLabel(labels = [], modelAliases = {}) {
38
+ const found = labelNames(labels).filter(n => n.startsWith(MODEL_LABEL_PREFIX));
39
+ if (found.length === 0) return null;
40
+ const aliases = found.map(n => n.slice(MODEL_LABEL_PREFIX.length));
41
+ if (found.length > 1) return { alias: aliases.join(', '), model: null, error: 'ambíguo' };
42
+ const model = modelAliases?.[aliases[0]];
43
+ return typeof model === 'string' && model.trim()
44
+ ? { alias: aliases[0], model: model.trim(), error: null }
45
+ : { alias: aliases[0], model: null, error: 'desconhecido' };
46
+ }
47
+
15
48
  /**
16
49
  * Resolve provider/modelo/teto de tokens de IA (função PURA — testável sem
17
50
  * process.env nem fs).
18
51
  *
19
- * Precedência do modelo: env.SPEC_WAVE_MODEL → fileAi.models[action] →
20
- * fileAi.modeldefault do provider. Provider: env.SPEC_WAVE_PROVIDER
21
- * fileAi.providerdefault. Assim uma ação específica (ex.: critique) pode
22
- * usar modelo próprio via bloco `ai.models` do .spec-wave.json.
52
+ * Precedência do modelo:
53
+ * env.SPEC_WAVE_MODELlabel `spec-wave:model:<alias>` modelOverride
54
+ * (escalada automática da crítica) → fileAi.models[action]fileAi.model
55
+ * default do provider.
23
56
  *
24
- * maxTokens segue a mesma forma: env.SPEC_WAVE_MAX_TOKENS
25
- * fileAi.maxTokensByAction[action] fileAi.maxTokens DEFAULT_MAX_TOKENS.
57
+ * A label vem ANTES da escalada de propósito: ela é decisão manual explícita do
58
+ * humano para aquela execução, e deve vencer a troca automática de modelo.
59
+ *
60
+ * Provider: env.SPEC_WAVE_PROVIDER → fileAi.provider → default.
61
+ *
62
+ * maxTokens: env.SPEC_WAVE_MAX_TOKENS → fileAi.maxTokensByAction[action] →
63
+ * fileAi.maxTokens → MAX_TOKENS_BY_ACTION[action] → DEFAULT_MAX_TOKENS.
26
64
  *
27
65
  * @param {object} params
28
66
  * @param {object} [params.env] objeto tipo process.env
29
67
  * @param {object} [params.fileAi] bloco `ai` do .spec-wave.json
30
68
  * @param {string} [params.action] ação de IA (ver AI_ACTIONS em config.mjs)
31
- * @returns {{ provider: string, model: string, secret: string, pricing: object|null, maxTokens: number }}
69
+ * @param {Array<string|{name:string}>} [params.labels] labels da issue em curso
70
+ * @param {string} [params.modelOverride] modelo imposto pelo chamador (escalada)
71
+ * @returns {{ provider, model, modelSource, labelAlias, secret, pricing,
72
+ * maxTokens, escalationModel, maxCritiqueAttempts }}
32
73
  */
33
- export function resolveAiConfig({ env = {}, fileAi = {}, action } = {}) {
74
+ export function resolveAiConfig({ env = {}, fileAi = {}, action, labels = [], modelOverride } = {}) {
34
75
  const provider = (env.SPEC_WAVE_PROVIDER || fileAi.provider || DEFAULT_PROVIDER).toLowerCase();
35
76
  const meta = getProvider(provider) || getProvider(DEFAULT_PROVIDER);
36
- const model = env.SPEC_WAVE_MODEL
37
- || (action && fileAi.models?.[action])
38
- || fileAi.model
39
- || meta.defaultModel;
77
+
78
+ const labelAlias = resolveModelLabel(labels, fileAi.modelAliases);
79
+ // Tabela em vez de cadeia de `||` para poder devolver a ORIGEM do modelo — sem
80
+ // isso, o log da Action não diz por que aquele modelo foi usado.
81
+ const [modelSource, rawModel] = [
82
+ ['env', env.SPEC_WAVE_MODEL],
83
+ ['label', labelAlias?.model],
84
+ ['escalation', modelOverride],
85
+ ['action', action ? fileAi.models?.[action] : undefined],
86
+ ['file', fileAi.model],
87
+ ['default', meta.defaultModel],
88
+ ].find(([, value]) => typeof value === 'string' && value.trim());
89
+
40
90
  // pricing: tabela `ai.pricing` do .spec-wave.json ({ [model]: { input,
41
91
  // output } } em USD/1M tokens) — usada para estimar custo quando o provider
42
92
  // não devolve o valor (Anthropic).
43
93
  const maxTokens = positiveInt(env.SPEC_WAVE_MAX_TOKENS)
44
94
  ?? (action ? positiveInt(fileAi.maxTokensByAction?.[action]) : undefined)
45
95
  ?? positiveInt(fileAi.maxTokens)
96
+ ?? (action ? MAX_TOKENS_BY_ACTION[action] : undefined)
46
97
  ?? DEFAULT_MAX_TOKENS;
98
+
47
99
  return {
48
100
  provider: meta.value,
49
- model,
101
+ model: rawModel.trim(),
102
+ modelSource,
103
+ labelAlias,
50
104
  secret: meta.secret,
51
105
  pricing: fileAi.pricing || null,
52
106
  maxTokens,
107
+ escalationModel: typeof fileAi.escalationModel === 'string'
108
+ ? (fileAi.escalationModel.trim() || null)
109
+ : null,
110
+ maxCritiqueAttempts: positiveInt(fileAi.maxCritiqueAttempts) ?? DEFAULT_MAX_CRITIQUE_ATTEMPTS,
53
111
  };
54
112
  }
55
113
 
@@ -60,21 +118,27 @@ function positiveInt(value) {
60
118
  return Number.isInteger(n) && n > 0 ? n : undefined;
61
119
  }
62
120
 
63
- // Resolve o provider/modelo de IA a partir do .spec-wave.json (gravado pelo init
64
- // e versionado no repo) com precedência para variáveis de ambiente — assim os
65
- // workflows usam exatamente o que foi escolhido no init, sem depender de flags.
66
- // Wrapper fino: o arquivo e delega a decisão à resolveAiConfig (pura).
67
- function resolveAi(action) {
68
- let fileAi = {};
121
+ /**
122
+ * Bloco `ai` do .spec-wave.json (ou {} se ausente/corrompido).
123
+ * Procura o config subindo na árvore comandos rodados de um subdiretório
124
+ * precisam achar a mesma configuração que a raiz.
125
+ */
126
+ export function readFileAi(cwd = process.cwd()) {
69
127
  try {
70
- const configPath = path.join(process.cwd(), CONFIG_FILE);
71
- if (existsSync(configPath)) {
72
- fileAi = JSON.parse(readFileSync(configPath, 'utf-8')).ai || {};
73
- }
128
+ const configPath = findConfigPath(cwd);
129
+ if (configPath) return JSON.parse(readFileSync(configPath, 'utf-8')).ai || {};
74
130
  } catch {
75
131
  // config ausente/corrompido → cai nos defaults/env
76
132
  }
77
- return resolveAiConfig({ env: process.env, fileAi, action });
133
+ return {};
134
+ }
135
+
136
+ // Resolve o provider/modelo de IA a partir do .spec-wave.json (gravado pelo init
137
+ // e versionado no repo) com precedência para variáveis de ambiente — assim os
138
+ // workflows usam exatamente o que foi escolhido no init, sem depender de flags.
139
+ // Wrapper fino: lê o arquivo e delega a decisão à resolveAiConfig (pura).
140
+ function resolveAi(action, { labels = [], modelOverride } = {}) {
141
+ return resolveAiConfig({ env: process.env, fileAi: readFileAi(), action, labels, modelOverride });
78
142
  }
79
143
 
80
144
  // Retry de falha transitória do provedor. Sem isto, um corpo cortado numa
@@ -158,6 +222,80 @@ export function supportsTemperature(model) {
158
222
  return !MODELS_WITHOUT_SAMPLING_PARAMS.test(model || '');
159
223
  }
160
224
 
225
+ // Modelos que aceitam `strict: true` na definição de tool (structured outputs).
226
+ // O default do repo (claude-sonnet-4-6) NÃO está na lista — enviar strict lá
227
+ // devolve 400. Sem strict, o tool_choice forçado ainda garante um bloco tool_use
228
+ // com input JSON; quem fecha o contrato é o validador do schema.
229
+ const MODELS_WITH_STRICT_SCHEMA =
230
+ /claude-(?:fable-5|mythos-5|opus-5|opus-4[.-](?:1|5|8)|sonnet-5|haiku-4[.-]5)/i;
231
+
232
+ /** O modelo aceita `strict: true` na definição de tool? (função PURA) */
233
+ export function supportsStrictSchema(model) {
234
+ return MODELS_WITH_STRICT_SCHEMA.test(model || '');
235
+ }
236
+
237
+ /**
238
+ * Corpo EXATO de `client.messages.create` (função PURA — testável sem HTTP).
239
+ *
240
+ * Com `schema`, força a ferramenta via tool_choice: o modelo não tem como
241
+ * responder em texto livre, que é o que produzia JSON sujo na crítica.
242
+ * `disable_parallel_tool_use` garante no máximo um bloco tool_use.
243
+ */
244
+ export function buildAnthropicRequest({
245
+ ai, systemPrompt, userContent, temperature, maxTokens, schema, strict,
246
+ }) {
247
+ return {
248
+ model: ai.model,
249
+ max_tokens: maxTokens,
250
+ // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5):
251
+ // enviá-la devolve 400.
252
+ ...(temperature === undefined ? {} : { temperature }),
253
+ messages: [{ role: 'user', content: userContent }],
254
+ system: systemPrompt,
255
+ ...(schema ? {
256
+ tools: [{
257
+ name: schema.name,
258
+ description: schema.description,
259
+ input_schema: schema.jsonSchema,
260
+ ...(strict ? { strict: true } : {}),
261
+ }],
262
+ tool_choice: { type: 'tool', name: schema.name, disable_parallel_tool_use: true },
263
+ } : {}),
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Corpo EXATO do POST da OpenRouter (função PURA — testável sem HTTP).
269
+ *
270
+ * `provider.require_parameters` é obrigatório junto do response_format: sem ele o
271
+ * roteador escolhe endpoints que IGNORAM o schema e devolvem prosa, e o erro só
272
+ * apareceria no JSON.parse, três retries depois. Com ele, a recusa é imediata e
273
+ * a mensagem diz o que trocar.
274
+ */
275
+ export function buildOpenRouterBody({
276
+ ai, systemPrompt, userContent, temperature, maxTokens, schema,
277
+ }) {
278
+ return {
279
+ model: ai.model,
280
+ max_tokens: maxTokens,
281
+ // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5).
282
+ ...(temperature === undefined ? {} : { temperature }),
283
+ messages: [
284
+ { role: 'system', content: systemPrompt },
285
+ { role: 'user', content: userContent },
286
+ ],
287
+ // Pede o bloco `usage` completo na resposta (inclui `cost` em USD).
288
+ usage: { include: true },
289
+ ...(schema ? {
290
+ response_format: {
291
+ type: 'json_schema',
292
+ json_schema: { name: schema.name, strict: true, schema: schema.jsonSchema },
293
+ },
294
+ provider: { require_parameters: true },
295
+ } : {}),
296
+ };
297
+ }
298
+
161
299
  // temperature padrão 0.2 (RFC-002 §5): "Determinism over Creativity". Pode ser
162
300
  // sobrescrita por chamada via opts, mas o default cobre spec/plan/decompose.
163
301
  //
@@ -173,7 +311,7 @@ export function supportsTemperature(model) {
173
311
  // model, inputTokens, outputTokens, cost } por invocação (o retry de lint
174
312
  // soma tokens/custo na MESMA entrada).
175
313
  export async function generateDocument(systemPrompt, userContent, opts = {}) {
176
- const ai = resolveAi(opts.action);
314
+ const ai = resolveAi(opts.action, { labels: opts.labels });
177
315
  const temperature = opts.temperature ?? 0.2;
178
316
  // Modelos de reasoning (ex.: deepseek-r1, Opus 4.7+) consomem tokens
179
317
  // "pensando" antes da resposta, então o teto precisa cobrir raciocínio +
@@ -181,7 +319,7 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
181
319
  const maxTokens = opts.maxTokens ?? ai.maxTokens;
182
320
  const sendTemperature = supportsTemperature(ai.model);
183
321
  console.log(
184
- `Provider de IA: ${ai.provider} · modelo: ${ai.model} · ` +
322
+ `Provider de IA: ${ai.provider} · modelo: ${ai.model} (origem: ${ai.modelSource}) · ` +
185
323
  `temperature: ${sendTemperature ? temperature : 'n/a (removida neste modelo)'} · ` +
186
324
  `max_tokens: ${maxTokens}`
187
325
  );
@@ -232,26 +370,95 @@ export async function generateDocument(systemPrompt, userContent, opts = {}) {
232
370
  }
233
371
  }
234
372
 
235
- // Telemetria: uma entrada por invocação no coletor passado pelo chamador.
236
- // A Anthropic não devolve custo — estima via tabela ai.pricing, se houver.
237
- if (Array.isArray(opts.usage)) {
238
- if (cost === null && ai.provider === 'anthropic') {
239
- cost = computeCost({ model: ai.model, inputTokens, outputTokens, pricing: ai.pricing });
240
- }
241
- opts.usage.push({
242
- at: new Date().toISOString(),
243
- action: opts.action || 'unknown',
244
- provider: ai.provider,
245
- model: ai.model,
246
- inputTokens,
247
- outputTokens,
248
- cost,
249
- });
250
- }
373
+ recordUsageEntry(opts, ai, { inputTokens, outputTokens, cost });
251
374
 
252
375
  return opts.withReport ? { content, lintFindings, retried } : content;
253
376
  }
254
377
 
378
+ // Telemetria: uma entrada por invocação no coletor passado pelo chamador.
379
+ // A Anthropic não devolve custo — estima via tabela ai.pricing, se houver.
380
+ // Extraída do generateDocument para o generateStructured usar a mesma contagem.
381
+ function recordUsageEntry(opts, ai, { inputTokens, outputTokens, cost }) {
382
+ if (!Array.isArray(opts.usage)) return;
383
+ const finalCost = cost === null && ai.provider === 'anthropic'
384
+ ? computeCost({ model: ai.model, inputTokens, outputTokens, pricing: ai.pricing })
385
+ : cost;
386
+ opts.usage.push({
387
+ at: new Date().toISOString(),
388
+ action: opts.action || 'unknown',
389
+ provider: ai.provider,
390
+ model: ai.model,
391
+ inputTokens,
392
+ outputTokens,
393
+ cost: finalCost,
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Geração com saída ESTRUTURADA e validada (JSON), não texto livre.
399
+ *
400
+ * Existe como função irmã de `generateDocument` — e não como um `opts.schema`
401
+ * nela — porque aquele caminho é string de ponta a ponta: remove cerca externa,
402
+ * roda lint de idioma e RE-GERA quando o lint reprova. Um payload JSON não deve
403
+ * passar por nada disso; só o lint já dobraria o custo de cada crítica.
404
+ *
405
+ * Anthropic: tool call forçado. OpenRouter: response_format json_schema +
406
+ * provider.require_parameters. A validação do schema roda DENTRO do retry —
407
+ * pedir de novo é exatamente o que pode resolver (ao contrário de truncamento).
408
+ * Esgotadas as tentativas o erro SOBE: nunca se devolve texto bruto disfarçado
409
+ * de resultado, que era a origem do vazamento de JSON nos comentários da issue.
410
+ *
411
+ * @param {string} systemPrompt
412
+ * @param {string} userContent
413
+ * @param {object} opts
414
+ * @param {{name,description,jsonSchema,validate?}} opts.schema `validate` é pura,
415
+ * lança em payload inválido e devolve o valor normalizado
416
+ * @param {string} [opts.action] ação de IA (modelo/teto por ação)
417
+ * @param {Array} [opts.labels] labels da issue (override por label)
418
+ * @param {string} [opts.model] modelo imposto (escalada da crítica)
419
+ * @param {boolean} [opts.withReport] devolve { value, model, provider, modelSource }
420
+ * @returns {Promise<any>} valor validado (ou o relatório, com withReport)
421
+ */
422
+ export async function generateStructured(systemPrompt, userContent, opts = {}) {
423
+ const schema = opts.schema;
424
+ if (!schema?.name || !schema?.jsonSchema) {
425
+ throw new Error('generateStructured exige opts.schema { name, jsonSchema }.');
426
+ }
427
+
428
+ const ai = resolveAi(opts.action, { labels: opts.labels, modelOverride: opts.model });
429
+ const temperature = opts.temperature ?? 0.2;
430
+ const maxTokens = opts.maxTokens ?? ai.maxTokens;
431
+ const sendTemperature = supportsTemperature(ai.model);
432
+ const strict = opts.strict ?? supportsStrictSchema(ai.model);
433
+
434
+ console.log(
435
+ `Provider de IA: ${ai.provider} · modelo: ${ai.model} (origem: ${ai.modelSource}) · ` +
436
+ `saída estruturada: ${schema.name}${strict ? ' (strict)' : ''} · max_tokens: ${maxTokens}`
437
+ );
438
+
439
+ const callOpts = {
440
+ temperature: sendTemperature ? temperature : undefined,
441
+ maxTokens,
442
+ schema,
443
+ strict,
444
+ };
445
+
446
+ // A validação roda DENTRO do retry de propósito: um payload fora do schema é
447
+ // marcado como transitório, e repetir a mesma requisição costuma resolver.
448
+ const { value, usage } = await withRetry(`Geração estruturada via ${ai.provider}`, async () => {
449
+ const res = ai.provider === 'openrouter'
450
+ ? await generateWithOpenRouter(systemPrompt, userContent, ai, callOpts)
451
+ : await generateWithAnthropic(systemPrompt, userContent, ai, callOpts);
452
+ return { value: schema.validate ? schema.validate(res.json) : res.json, usage: res.usage };
453
+ }, opts.retry);
454
+
455
+ recordUsageEntry(opts, ai, usage);
456
+
457
+ return opts.withReport
458
+ ? { value, model: ai.model, provider: ai.provider, modelSource: ai.modelSource }
459
+ : value;
460
+ }
461
+
255
462
  // Remove blocos de raciocínio que alguns modelos (ex.: deepseek-r1) embutem no
256
463
  // content. A OpenRouter normalmente separa em `reasoning`, mas isto é uma rede
257
464
  // de segurança caso o <think> venha junto do conteúdo final.
@@ -299,7 +506,9 @@ export function extractOpenRouterUsage(usage) {
299
506
  };
300
507
  }
301
508
 
302
- async function generateWithAnthropic(systemPrompt, userContent, ai, { temperature, maxTokens }) {
509
+ async function generateWithAnthropic(
510
+ systemPrompt, userContent, ai, { temperature, maxTokens, schema, strict }
511
+ ) {
303
512
  const apiKey = process.env.ANTHROPIC_API_KEY;
304
513
  if (!apiKey) {
305
514
  throw new Error(
@@ -309,15 +518,9 @@ async function generateWithAnthropic(systemPrompt, userContent, ai, { temperatur
309
518
  }
310
519
 
311
520
  const client = new Anthropic({ apiKey });
312
- const message = await client.messages.create({
313
- model: ai.model,
314
- max_tokens: maxTokens,
315
- // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5):
316
- // enviá-la devolve 400.
317
- ...(temperature === undefined ? {} : { temperature }),
318
- messages: [{ role: 'user', content: userContent }],
319
- system: systemPrompt,
320
- });
521
+ const message = await client.messages.create(buildAnthropicRequest({
522
+ ai, systemPrompt, userContent, temperature, maxTokens, schema, strict,
523
+ }));
321
524
 
322
525
  // A resposta nem sempre começa com um bloco de texto (recusa, resposta vazia):
323
526
  // `content[0].text` cru virava TypeError com mensagem inútil.
@@ -325,6 +528,10 @@ async function generateWithAnthropic(systemPrompt, userContent, ai, { temperatur
325
528
  .filter((block) => block.type === 'text')
326
529
  .map((block) => block.text)
327
530
  .join('');
531
+ // Com tool_choice forçado a resposta traz um bloco tool_use e NENHUM bloco de
532
+ // texto — daí a necessidade do branch dedicado antes da checagem de texto vazio.
533
+ const tool = (message.content || [])
534
+ .find((block) => block.type === 'tool_use' && block.name === schema?.name);
328
535
 
329
536
  if (message.stop_reason === 'refusal') {
330
537
  throw new Error(
@@ -347,9 +554,31 @@ async function generateWithAnthropic(systemPrompt, userContent, ai, { temperatur
347
554
  model: ai.model,
348
555
  maxTokens,
349
556
  reason: message.stop_reason,
350
- chars: text.length,
557
+ // Sob tool call forçado não há bloco de texto: medir `text.length` diria
558
+ // sempre "~0 caracteres antes do corte".
559
+ chars: schema ? JSON.stringify(tool?.input ?? '').length : text.length,
351
560
  });
352
561
  }
562
+
563
+ // ATENÇÃO: este branch precisa vir ANTES da checagem de `!text` — uma resposta
564
+ // com tool call forçado não tem bloco de texto, e cair no `!text` faria toda
565
+ // crítica queimar os 3 retries com a mensagem errada.
566
+ if (schema) {
567
+ if (!tool || typeof tool.input !== 'object' || tool.input === null) {
568
+ const err = new Error(
569
+ `A Anthropic não devolveu a ferramenta \`${schema.name}\` ` +
570
+ `(stop_reason=${message.stop_reason}). Conteúdo: ${text.slice(0, 200) || '(vazio)'}`
571
+ );
572
+ err.transient = true; // ferramenta forçada: repetir a requisição costuma resolver
573
+ throw err;
574
+ }
575
+ return {
576
+ text: JSON.stringify(tool.input),
577
+ json: tool.input,
578
+ usage: extractAnthropicUsage(message.usage),
579
+ };
580
+ }
581
+
353
582
  if (!text) {
354
583
  const err = new Error(
355
584
  `A Anthropic retornou resposta sem texto (stop_reason=${message.stop_reason}).`
@@ -361,7 +590,9 @@ async function generateWithAnthropic(systemPrompt, userContent, ai, { temperatur
361
590
  return { text, usage: extractAnthropicUsage(message.usage) };
362
591
  }
363
592
 
364
- async function generateWithOpenRouter(systemPrompt, userContent, ai, { temperature, maxTokens }) {
593
+ async function generateWithOpenRouter(
594
+ systemPrompt, userContent, ai, { temperature, maxTokens, schema }
595
+ ) {
365
596
  const apiKey = process.env.OPENROUTER_API_KEY;
366
597
  if (!apiKey) {
367
598
  throw new Error(
@@ -378,23 +609,22 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, { temperatu
378
609
  'HTTP-Referer': 'https://github.com/moacsjr/spec-wave',
379
610
  'X-Title': 'spec-wave',
380
611
  },
381
- body: JSON.stringify({
382
- model: ai.model,
383
- max_tokens: maxTokens,
384
- // Omitida nos modelos que removeram sampling (Opus 4.7+, Sonnet 5, Fable 5).
385
- ...(temperature === undefined ? {} : { temperature }),
386
- messages: [
387
- { role: 'system', content: systemPrompt },
388
- { role: 'user', content: userContent },
389
- ],
390
- // Pede o bloco `usage` completo na resposta (inclui `cost` em USD).
391
- usage: { include: true },
392
- }),
612
+ body: JSON.stringify(buildOpenRouterBody({
613
+ ai, systemPrompt, userContent, temperature, maxTokens, schema,
614
+ })),
393
615
  });
394
616
 
395
617
  if (!res.ok) {
396
618
  const body = await res.text();
397
- const err = new Error(`OpenRouter API ${res.status}: ${body}`);
619
+ const err = new Error(
620
+ `OpenRouter API ${res.status}: ${body}` +
621
+ // require_parameters faz o roteador recusar quando nenhum provedor do modelo
622
+ // implementa response_format — erro de CONFIGURAÇÃO, não de rede.
623
+ (schema && (res.status === 404 || res.status === 400)
624
+ ? `\nNenhum provedor de ${ai.model} suporta response_format/json_schema. ` +
625
+ 'Aponte `ai.models.critique` no .spec-wave.json para um modelo com saída estruturada.'
626
+ : '')
627
+ );
398
628
  err.status = res.status;
399
629
  throw err;
400
630
  }
@@ -437,5 +667,20 @@ async function generateWithOpenRouter(systemPrompt, userContent, ai, { temperatu
437
667
  err.transient = true;
438
668
  throw err;
439
669
  }
670
+
671
+ if (schema) {
672
+ const raw = stripOuterFence(content);
673
+ try {
674
+ return { text: raw, json: JSON.parse(raw), usage: extractOpenRouterUsage(data.usage) };
675
+ } catch {
676
+ const err = new Error(
677
+ `O modelo ${ai.model} ignorou response_format e devolveu texto não-JSON ` +
678
+ `(${raw.length} bytes): ${raw.slice(0, 200)}`
679
+ );
680
+ err.transient = true; // ≠ truncamento: repetir pode dar certo
681
+ throw err;
682
+ }
683
+ }
684
+
440
685
  return { text: content, usage: extractOpenRouterUsage(data.usage) };
441
686
  }