@spec-wave/cli 0.13.0 → 0.15.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.
- package/README.md +39 -6
- package/bin/spec-wave.mjs +17 -1
- package/package.json +1 -1
- package/src/api/github-rest.mjs +198 -2
- package/src/commands/code-review.mjs +5 -8
- package/src/commands/decompose.mjs +412 -130
- package/src/commands/dev-agent.mjs +3 -2
- package/src/commands/doctor.mjs +239 -9
- package/src/commands/generate-plan.mjs +105 -30
- package/src/commands/generate-spec.mjs +17 -5
- package/src/commands/implement.mjs +39 -15
- package/src/commands/info.mjs +4 -3
- package/src/commands/issue.mjs +4 -4
- package/src/commands/move.mjs +162 -0
- package/src/commands/order.mjs +1 -12
- package/src/commands/qa.mjs +5 -8
- package/src/commands/refresh.mjs +4 -3
- package/src/commands/story.mjs +1 -12
- package/src/commands/task.mjs +1 -11
- package/src/commands/update.mjs +372 -71
- package/src/commands/validate.mjs +37 -22
- package/src/config.mjs +40 -1
- package/src/lib/board.mjs +88 -26
- package/src/lib/claude.mjs +315 -70
- package/src/lib/critique.mjs +391 -91
- package/src/lib/decomposition-doc.mjs +451 -0
- package/src/lib/implement-board.mjs +14 -1
- package/src/lib/pr-branch.mjs +267 -0
- package/src/lib/project-root.mjs +93 -0
- package/src/lib/templates.mjs +53 -0
- package/src/setup/files.mjs +3 -10
- package/src/templates/skill/SKILL.md +158 -30
- package/src/templates/workflows/code-review.yml +1 -1
- package/src/templates/workflows/decompose.yml +20 -6
- package/src/templates/workflows/generate-plan.yml +1 -1
- package/src/templates/workflows/generate-spec.yml +1 -1
- package/src/templates/workflows/qa.yml +1 -1
- package/src/templates/workflows/validate.yml +1 -1
package/src/lib/claude.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
import { readFileSync
|
|
3
|
-
import
|
|
4
|
-
|
|
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:
|
|
20
|
-
*
|
|
21
|
-
* fileAi.
|
|
22
|
-
*
|
|
52
|
+
* Precedência do modelo:
|
|
53
|
+
* env.SPEC_WAVE_MODEL → label `spec-wave:model:<alias>` → modelOverride
|
|
54
|
+
* (escalada automática da crítica) → fileAi.models[action] → fileAi.model
|
|
55
|
+
* → default do provider.
|
|
23
56
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
71
|
-
if (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
383
|
-
|
|
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(
|
|
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
|
}
|