@planningo/duul 1.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +438 -0
  3. package/README.md +463 -0
  4. package/build/index.d.ts +2 -0
  5. package/build/index.js +18 -0
  6. package/build/prompts/code-review-system.d.ts +9 -0
  7. package/build/prompts/code-review-system.js +116 -0
  8. package/build/prompts/execution-partition-system.d.ts +11 -0
  9. package/build/prompts/execution-partition-system.js +76 -0
  10. package/build/prompts/plan-review-system.d.ts +29 -0
  11. package/build/prompts/plan-review-system.js +175 -0
  12. package/build/schemas/code-review.d.ts +514 -0
  13. package/build/schemas/code-review.js +175 -0
  14. package/build/schemas/common.d.ts +118 -0
  15. package/build/schemas/common.js +64 -0
  16. package/build/schemas/execution-partition.d.ts +597 -0
  17. package/build/schemas/execution-partition.js +107 -0
  18. package/build/schemas/plan-review.d.ts +523 -0
  19. package/build/schemas/plan-review.js +175 -0
  20. package/build/services/filesystem-tools.d.ts +6 -0
  21. package/build/services/filesystem-tools.js +39 -0
  22. package/build/services/filesystem.d.ts +69 -0
  23. package/build/services/filesystem.js +609 -0
  24. package/build/services/pricing.d.ts +8 -0
  25. package/build/services/pricing.js +105 -0
  26. package/build/services/providers/anthropic.d.ts +28 -0
  27. package/build/services/providers/anthropic.js +431 -0
  28. package/build/services/providers/google.d.ts +28 -0
  29. package/build/services/providers/google.js +358 -0
  30. package/build/services/providers/openai.d.ts +22 -0
  31. package/build/services/providers/openai.js +395 -0
  32. package/build/services/providers/types.d.ts +82 -0
  33. package/build/services/providers/types.js +1 -0
  34. package/build/services/review-gates.d.ts +83 -0
  35. package/build/services/review-gates.js +200 -0
  36. package/build/services/review-limits.d.ts +36 -0
  37. package/build/services/review-limits.js +65 -0
  38. package/build/services/reviewer.d.ts +30 -0
  39. package/build/services/reviewer.js +243 -0
  40. package/build/services/usage-logger.d.ts +2 -0
  41. package/build/services/usage-logger.js +42 -0
  42. package/build/tools/code-review.d.ts +2 -0
  43. package/build/tools/code-review.js +178 -0
  44. package/build/tools/execution-partition.d.ts +2 -0
  45. package/build/tools/execution-partition.js +146 -0
  46. package/build/tools/plan-review.d.ts +2 -0
  47. package/build/tools/plan-review.js +183 -0
  48. package/package.json +65 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Model pricing table and cost estimation.
3
+ *
4
+ * Prices are per 1M tokens in USD.
5
+ * Update this table when new models or pricing changes occur.
6
+ * Set env DUUL_PRICING_JSON to a JSON file path to override/extend.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ // Key: model name or prefix. Longest prefix match wins.
10
+ const PRICING_TABLE = {
11
+ // OpenAI — GPT
12
+ 'gpt-4o-mini': { input: 0.15, output: 0.60 },
13
+ 'gpt-4o': { input: 2.50, output: 10.00 },
14
+ 'gpt-4.1-nano': { input: 0.10, output: 0.40 },
15
+ 'gpt-4.1-mini': { input: 0.40, output: 1.60 },
16
+ 'gpt-4.1': { input: 2.00, output: 8.00 },
17
+ 'gpt-4.5-preview': { input: 75.00, output: 150.00 },
18
+ 'gpt-5': { input: 2.00, output: 8.00 },
19
+ 'gpt-5.4': { input: 2.00, output: 8.00 },
20
+ // OpenAI — o-series (reasoning)
21
+ 'o4-mini': { input: 1.10, output: 4.40 },
22
+ 'o3': { input: 2.00, output: 8.00 },
23
+ 'o3-pro': { input: 20.00, output: 80.00 },
24
+ 'o3-mini': { input: 1.10, output: 4.40 },
25
+ 'o1': { input: 15.00, output: 60.00 },
26
+ 'o1-mini': { input: 1.10, output: 4.40 },
27
+ 'o1-preview': { input: 15.00, output: 60.00 },
28
+ // Anthropic — Claude
29
+ 'claude-opus-4': { input: 15.00, output: 75.00 },
30
+ 'claude-sonnet-4': { input: 3.00, output: 15.00 },
31
+ 'claude-haiku-4': { input: 0.80, output: 4.00 },
32
+ 'claude-3.5-sonnet': { input: 3.00, output: 15.00 },
33
+ 'claude-3.5-haiku': { input: 0.80, output: 4.00 },
34
+ 'claude-3-opus': { input: 15.00, output: 75.00 },
35
+ 'claude-3-sonnet': { input: 3.00, output: 15.00 },
36
+ 'claude-3-haiku': { input: 0.25, output: 1.25 },
37
+ // Google — Gemini
38
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
39
+ 'gemini-2.5-flash': { input: 0.15, output: 0.60 },
40
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40 },
41
+ 'gemini-3': { input: 1.25, output: 10.00 },
42
+ 'gemini-3.1-pro': { input: 1.25, output: 10.00 },
43
+ };
44
+ // Custom pricing loaded lazily from DUUL_PRICING_JSON
45
+ let customPricing = null;
46
+ function loadCustomPricing() {
47
+ if (customPricing !== null)
48
+ return customPricing;
49
+ customPricing = {};
50
+ const envPath = process.env.DUUL_PRICING_JSON;
51
+ if (envPath) {
52
+ try {
53
+ const data = JSON.parse(readFileSync(envPath, 'utf-8'));
54
+ if (typeof data === 'object' && data !== null) {
55
+ customPricing = data;
56
+ console.error(`[duul] Loaded custom pricing from ${envPath} (${Object.keys(customPricing).length} models)`);
57
+ }
58
+ }
59
+ catch {
60
+ console.error(`[duul] Failed to load custom pricing from ${envPath}, using defaults`);
61
+ }
62
+ }
63
+ return customPricing;
64
+ }
65
+ function findPricing(model) {
66
+ const custom = loadCustomPricing();
67
+ // Exact match (custom first, then built-in)
68
+ if (custom[model])
69
+ return custom[model];
70
+ if (PRICING_TABLE[model])
71
+ return PRICING_TABLE[model];
72
+ // Longest prefix match (e.g. "gpt-4.1-mini-2025-04-14" → "gpt-4.1-mini")
73
+ let bestMatch = null;
74
+ let bestLen = 0;
75
+ const allKeys = [...Object.keys(custom), ...Object.keys(PRICING_TABLE)];
76
+ for (const key of allKeys) {
77
+ if (model.startsWith(key) && key.length > bestLen) {
78
+ bestMatch = custom[key] ?? PRICING_TABLE[key];
79
+ bestLen = key.length;
80
+ }
81
+ }
82
+ return bestMatch;
83
+ }
84
+ /**
85
+ * Cache-aware cost estimate. `inputTokens` is the provider-reported total input
86
+ * bucket. `cachedInputTokens` (cache reads, 0.1× input price) and
87
+ * `cacheCreationTokens` (Anthropic cache writes, 1.25× input price) are
88
+ * already included in that total, so we subtract them from the full-price
89
+ * bucket before pricing.
90
+ */
91
+ export function estimateCost(model, inputTokens, outputTokens, cachedInputTokens = 0, cacheCreationTokens = 0) {
92
+ const pricing = findPricing(model);
93
+ if (!pricing)
94
+ return null;
95
+ const cached = Math.max(0, cachedInputTokens);
96
+ const cacheWrite = Math.max(0, cacheCreationTokens);
97
+ const nonCachedInput = Math.max(0, inputTokens - cached - cacheWrite);
98
+ const nonCachedInputCost = (nonCachedInput / 1_000_000) * pricing.input;
99
+ const cacheReadCost = (cached / 1_000_000) * pricing.input * 0.1;
100
+ const cacheWriteCost = (cacheWrite / 1_000_000) * pricing.input * 1.25;
101
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
102
+ const total = nonCachedInputCost + cacheReadCost + cacheWriteCost + outputCost;
103
+ // Round to 6 decimal places to avoid floating point noise
104
+ return Math.round(total * 1_000_000) / 1_000_000;
105
+ }
@@ -0,0 +1,28 @@
1
+ import type { z } from 'zod';
2
+ import type { ReviewerProvider, ReviewCallOptions, ReviewCallResult, ProviderCapabilities } from './types.js';
3
+ /**
4
+ * Anthropic provider — uses Claude models via the Anthropic Messages API.
5
+ *
6
+ * Capabilities:
7
+ * - Tool calling via native Anthropic tools parameter
8
+ * - Conversation history for simulated context persistence across rounds
9
+ * - No native structured outputs (uses JSON prompt + zod validation)
10
+ */
11
+ export declare class AnthropicProvider implements ReviewerProvider {
12
+ readonly name = "anthropic";
13
+ readonly capabilities: ProviderCapabilities;
14
+ private apiKey;
15
+ private baseUrl;
16
+ private model;
17
+ private temperature;
18
+ private topP;
19
+ constructor(config?: {
20
+ apiKey?: string;
21
+ baseUrl?: string;
22
+ model?: string;
23
+ temperature?: number;
24
+ topP?: number;
25
+ });
26
+ review<T extends z.ZodType>(options: ReviewCallOptions<T>): Promise<ReviewCallResult<z.infer<T>>>;
27
+ private apiCallWithRetry;
28
+ }
@@ -0,0 +1,431 @@
1
+ import { validateProjectRoot } from '../filesystem.js';
2
+ import { executeFilesystemTool } from '../filesystem-tools.js';
3
+ import { estimateCost } from '../pricing.js';
4
+ const MAX_INPUT_CHARS = 400_000;
5
+ const MAX_TOOL_ROUNDS = 10;
6
+ const MAX_RETRIES = 3;
7
+ const MAX_REPEAT_CALLS = 3;
8
+ const LINKED_PATH_HINT = ' To access a linked root, prefix with "linked:<index>:<path>" (e.g. "linked:0:src/types.ts").';
9
+ /**
10
+ * Anthropic tool definitions in Claude Messages API format.
11
+ * `cache_control` on the last tool marks the end of the static tool prefix
12
+ * so Anthropic caches system + all tools as one contiguous block.
13
+ */
14
+ const ANTHROPIC_TOOLS = [
15
+ {
16
+ name: 'read_file',
17
+ description: 'Read a file from the project being reviewed. Use this to examine type definitions, ' +
18
+ 'data models, interfaces, utility functions, and any code you need for thorough review context. ' +
19
+ 'Returns file contents as text.' + LINKED_PATH_HINT,
20
+ input_schema: {
21
+ type: 'object',
22
+ properties: {
23
+ path: { type: 'string', description: 'Relative path from project root, or "linked:<index>:<path>" for linked roots' },
24
+ },
25
+ required: ['path'],
26
+ },
27
+ },
28
+ {
29
+ name: 'list_directory',
30
+ description: 'List files and directories at a path in the project. Use this to understand project structure.' + LINKED_PATH_HINT,
31
+ input_schema: {
32
+ type: 'object',
33
+ properties: {
34
+ path: { type: 'string', description: 'Relative path from project root. Use "." for project root, or "linked:<index>:<path>" for linked roots.' },
35
+ },
36
+ required: ['path'],
37
+ },
38
+ },
39
+ {
40
+ name: 'search_in_files',
41
+ description: 'Search for a pattern across project files. Uses ripgrep when available, falls back to git grep. ' +
42
+ 'PREFER this over read_file when you need to find specific symbols, keywords, or patterns. ' +
43
+ 'Returns matching lines with file paths and line numbers.',
44
+ input_schema: {
45
+ type: 'object',
46
+ properties: {
47
+ query: { type: 'string', description: 'Search pattern (literal string or regex)' },
48
+ paths: { type: 'array', items: { type: 'string' }, description: 'Optional: restrict search to these relative paths/directories' },
49
+ glob: { type: 'string', description: 'Optional: glob pattern to filter files (e.g. "*.ts", "src/**/*.js")' },
50
+ },
51
+ required: ['query'],
52
+ },
53
+ },
54
+ {
55
+ name: 'read_file_range',
56
+ description: 'Read a specific line range from a file. PREFER this over read_file for large files. Returns numbered lines. Max 200 lines per call.' + LINKED_PATH_HINT,
57
+ input_schema: {
58
+ type: 'object',
59
+ properties: {
60
+ path: { type: 'string', description: 'Relative path from project root, or "linked:<index>:<path>" for linked roots' },
61
+ start_line: { type: 'number', description: 'First line to read (1-based)' },
62
+ end_line: { type: 'number', description: 'Last line to read (1-based, inclusive)' },
63
+ },
64
+ required: ['path', 'start_line', 'end_line'],
65
+ },
66
+ },
67
+ {
68
+ name: 'stat_file',
69
+ description: 'Get file metadata: size, type, and modification time. Use this before read_file to check if a file is too large.' + LINKED_PATH_HINT,
70
+ input_schema: {
71
+ type: 'object',
72
+ properties: {
73
+ path: { type: 'string', description: 'Relative path from project root, or "linked:<index>:<path>" for linked roots' },
74
+ },
75
+ required: ['path'],
76
+ },
77
+ },
78
+ {
79
+ name: 'read_json',
80
+ description: 'Read a JSON file, optionally extracting a value at a JSON pointer path. Use this for package.json, tsconfig.json, etc.' + LINKED_PATH_HINT,
81
+ input_schema: {
82
+ type: 'object',
83
+ properties: {
84
+ path: { type: 'string', description: 'Relative path to a JSON file, or "linked:<index>:<path>" for linked roots' },
85
+ json_pointer: { type: 'string', description: 'Optional: JSON pointer (e.g. "/dependencies", "/scripts/build")' },
86
+ },
87
+ required: ['path'],
88
+ },
89
+ },
90
+ {
91
+ name: 'list_tracked_files',
92
+ description: 'List git-tracked files, optionally filtered by a directory prefix. Only shows files committed to git.',
93
+ input_schema: {
94
+ type: 'object',
95
+ properties: {
96
+ prefix: { type: 'string', description: 'Optional: directory prefix to filter (e.g. "src/")' },
97
+ },
98
+ required: [],
99
+ },
100
+ },
101
+ {
102
+ name: 'get_git_diff',
103
+ description: 'Get git diff output for the workspace. Use this to see exactly what changed — ' +
104
+ 'PREFER this over reading full files when reviewing modifications. ' +
105
+ 'Returns unified diff format. Defaults to comparing against HEAD (current workspace changes). Also includes untracked new files.',
106
+ input_schema: {
107
+ type: 'object',
108
+ properties: {
109
+ base: { type: 'string', description: 'Base ref to diff against (e.g. "HEAD~1", "main", a commit SHA). Defaults to HEAD.' },
110
+ paths: { type: 'array', items: { type: 'string' }, description: 'Optional: restrict diff to these relative paths' },
111
+ },
112
+ required: [],
113
+ },
114
+ cache_control: { type: 'ephemeral' },
115
+ },
116
+ ];
117
+ const FULL_READ_TOOLS = new Set(['read_file', 'get_git_diff']);
118
+ const SEARCH_ONLY_TOOLS = new Set(['search_in_files', 'list_tracked_files', 'stat_file']);
119
+ /**
120
+ * Anthropic provider — uses Claude models via the Anthropic Messages API.
121
+ *
122
+ * Capabilities:
123
+ * - Tool calling via native Anthropic tools parameter
124
+ * - Conversation history for simulated context persistence across rounds
125
+ * - No native structured outputs (uses JSON prompt + zod validation)
126
+ */
127
+ export class AnthropicProvider {
128
+ name = 'anthropic';
129
+ capabilities = {
130
+ structuredOutputs: false,
131
+ toolCalling: true,
132
+ previousResponseId: true, // simulated via conversation history
133
+ jsonSchemaStrict: false,
134
+ };
135
+ apiKey;
136
+ baseUrl;
137
+ model;
138
+ temperature;
139
+ topP;
140
+ constructor(config) {
141
+ const apiKey = config?.apiKey ?? process.env.ANTHROPIC_API_KEY;
142
+ if (!apiKey) {
143
+ throw new Error('ANTHROPIC_API_KEY environment variable is not set');
144
+ }
145
+ this.apiKey = apiKey;
146
+ this.baseUrl = config?.baseUrl ?? 'https://api.anthropic.com';
147
+ this.model = config?.model ?? 'claude-opus-4-20250514';
148
+ this.temperature = config?.temperature ?? 0.2;
149
+ this.topP = config?.topP ?? 0.1;
150
+ }
151
+ async review(options) {
152
+ const { systemPrompt, userMessage, outputSchema, workspaceScope, conversationHistory } = options;
153
+ const effectiveRoot = workspaceScope?.root ?? null;
154
+ if (effectiveRoot && !workspaceScope) {
155
+ validateProjectRoot(effectiveRoot);
156
+ }
157
+ // Append JSON schema instruction to system prompt
158
+ const schemaJson = JSON.stringify(zodToJsonSchema(outputSchema), null, 2);
159
+ const enhancedSystem = `${systemPrompt}\n\n## Output Format\nYou MUST respond with ONLY a valid JSON object matching this schema. No markdown, no explanation, no code blocks — only the JSON object.\n\n${schemaJson}`;
160
+ // Wrap system prompt in a cacheable block so the provider returns
161
+ // cache_read_input_tokens on rounds 2+.
162
+ const systemBlocks = [
163
+ { type: 'text', text: enhancedSystem, cache_control: { type: 'ephemeral' } },
164
+ ];
165
+ const tools = effectiveRoot ? ANTHROPIC_TOOLS : undefined;
166
+ let allUsedTools = [];
167
+ // Accumulate token usage across all API calls
168
+ let totalInputTokens = 0;
169
+ let totalOutputTokens = 0;
170
+ let totalCachedInputTokens = 0;
171
+ let totalCacheCreationTokens = 0;
172
+ let apiCallCount = 0;
173
+ const accumulateUsage = (body) => {
174
+ apiCallCount++;
175
+ if (body.usage) {
176
+ // Anthropic reports input_tokens, cache_read_input_tokens, and
177
+ // cache_creation_input_tokens as three disjoint buckets. Sum them so
178
+ // totalInputTokens matches OpenAI's convention (cached tokens included
179
+ // in the input total) — that keeps total_tokens and estimateCost correct.
180
+ const nonCachedInput = body.usage.input_tokens ?? 0;
181
+ const cacheRead = body.usage.cache_read_input_tokens ?? 0;
182
+ const cacheWrite = body.usage.cache_creation_input_tokens ?? 0;
183
+ totalInputTokens += nonCachedInput + cacheRead + cacheWrite;
184
+ totalOutputTokens += body.usage.output_tokens ?? 0;
185
+ totalCachedInputTokens += cacheRead;
186
+ totalCacheCreationTokens += cacheWrite;
187
+ }
188
+ };
189
+ const buildUsage = () => ({
190
+ input_tokens: totalInputTokens,
191
+ output_tokens: totalOutputTokens,
192
+ total_tokens: totalInputTokens + totalOutputTokens,
193
+ api_calls: apiCallCount,
194
+ provider: 'anthropic',
195
+ model: this.model,
196
+ estimated_cost_usd: estimateCost(this.model, totalInputTokens, totalOutputTokens, totalCachedInputTokens, totalCacheCreationTokens),
197
+ ...(totalCachedInputTokens > 0 ? { cached_input_tokens: totalCachedInputTokens } : {}),
198
+ ...(totalCacheCreationTokens > 0 ? { cache_creation_input_tokens: totalCacheCreationTokens } : {}),
199
+ });
200
+ // Build messages array with optional conversation history
201
+ const messages = [];
202
+ if (conversationHistory?.length) {
203
+ for (const turn of conversationHistory) {
204
+ messages.push({ role: turn.role, content: turn.content });
205
+ }
206
+ }
207
+ messages.push({ role: 'user', content: userMessage });
208
+ // Track conversation turns for storage
209
+ const conversationTurns = [
210
+ ...(conversationHistory ?? []),
211
+ { role: 'user', content: userMessage },
212
+ ];
213
+ let body = await this.apiCallWithRetry(systemBlocks, messages, tools);
214
+ accumulateUsage(body);
215
+ console.error(`[duul] response.id=${body.id} model=${this.model} provider=anthropic`);
216
+ // Store assistant response
217
+ conversationTurns.push({ role: 'assistant', content: body.content });
218
+ // Agentic tool-calling loop
219
+ if (effectiveRoot) {
220
+ const toolReadBudget = MAX_INPUT_CHARS - (enhancedSystem.length + userMessage.length);
221
+ let accumulatedToolChars = 0;
222
+ const getStrategyLevel = () => {
223
+ const ratio = accumulatedToolChars / toolReadBudget;
224
+ if (ratio < 0.5)
225
+ return 0;
226
+ if (ratio < 0.8)
227
+ return 1;
228
+ if (ratio < 1.0)
229
+ return 2;
230
+ return 3;
231
+ };
232
+ const isToolAllowed = (toolName, level) => {
233
+ if (level >= 3)
234
+ return false;
235
+ if (level >= 2)
236
+ return SEARCH_ONLY_TOOLS.has(toolName);
237
+ if (level >= 1)
238
+ return !FULL_READ_TOOLS.has(toolName);
239
+ return true;
240
+ };
241
+ const budgetMessage = (toolName, level) => {
242
+ if (level >= 3)
243
+ return 'Budget exhausted. You must produce your final review verdict now with the context you already have.';
244
+ if (level >= 2)
245
+ return `[Budget Level 2] Only search/stat tools allowed. "${toolName}" blocked.`;
246
+ if (level >= 1)
247
+ return `[Budget Level 1] Full file reads blocked. "${toolName}" not allowed. Use read_file_range or search_in_files.`;
248
+ return '';
249
+ };
250
+ const toolCache = new Map();
251
+ const callCounts = new Map();
252
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
253
+ const toolUses = body.content.filter((b) => b.type === 'tool_use');
254
+ if (toolUses.length === 0 || body.stop_reason !== 'tool_use')
255
+ break;
256
+ const strategyLevel = getStrategyLevel();
257
+ console.error(`[duul] Tool round ${round + 1}: ${toolUses.length} call(s), budget ${accumulatedToolChars}/${toolReadBudget} (level ${strategyLevel})`);
258
+ const toolResults = [];
259
+ for (const call of toolUses) {
260
+ const args = call.input;
261
+ const cacheKey = `${call.name}:${JSON.stringify(args)}`;
262
+ const argSummary = (args.path ?? args.query ?? args.prefix ?? '');
263
+ const count = (callCounts.get(cacheKey) ?? 0) + 1;
264
+ callCounts.set(cacheKey, count);
265
+ if (count > MAX_REPEAT_CALLS) {
266
+ toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: 'You have already read this content multiple times. Use the context you already have to complete your review.' });
267
+ continue;
268
+ }
269
+ if (toolCache.has(cacheKey)) {
270
+ toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: toolCache.get(cacheKey) });
271
+ continue;
272
+ }
273
+ const currentLevel = getStrategyLevel();
274
+ if (!isToolAllowed(call.name, currentLevel)) {
275
+ toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: budgetMessage(call.name, currentLevel) });
276
+ continue;
277
+ }
278
+ const result = await executeFilesystemTool(effectiveRoot, call.name, args, workspaceScope);
279
+ toolCache.set(cacheKey, result);
280
+ allUsedTools.push(`${call.name}(${argSummary})`);
281
+ accumulatedToolChars += result.length;
282
+ console.error(`[duul] ${call.name}(${argSummary}) -> ${result.length} chars (total: ${accumulatedToolChars}/${toolReadBudget}, level ${getStrategyLevel()})`);
283
+ toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: result });
284
+ }
285
+ // Append tool results as a user message
286
+ messages.push({ role: 'assistant', content: body.content });
287
+ messages.push({ role: 'user', content: toolResults });
288
+ conversationTurns.push({ role: 'user', content: toolResults });
289
+ body = await this.apiCallWithRetry(systemBlocks, messages, tools);
290
+ accumulateUsage(body);
291
+ conversationTurns.push({ role: 'assistant', content: body.content });
292
+ console.error(`[duul] response.id=${body.id} (after tool round ${round + 1})`);
293
+ // Force verdict if budget exhausted
294
+ if (getStrategyLevel() >= 3) {
295
+ const pendingTools = body.content.filter((b) => b.type === 'tool_use');
296
+ if (pendingTools.length > 0) {
297
+ const stopResults = pendingTools.map((c) => ({
298
+ type: 'tool_result',
299
+ tool_use_id: c.id,
300
+ content: 'No more file reads allowed. You must produce your final review verdict now.',
301
+ }));
302
+ messages.push({ role: 'assistant', content: body.content });
303
+ messages.push({ role: 'user', content: stopResults });
304
+ body = await this.apiCallWithRetry(systemBlocks, messages, tools);
305
+ accumulateUsage(body);
306
+ conversationTurns.push({ role: 'user', content: stopResults });
307
+ conversationTurns.push({ role: 'assistant', content: body.content });
308
+ }
309
+ break;
310
+ }
311
+ }
312
+ // Handle pending tool calls after loop exhaustion
313
+ const pendingTools = body.content.filter((b) => b.type === 'tool_use');
314
+ if (pendingTools.length > 0 && body.stop_reason === 'tool_use') {
315
+ const stopResults = pendingTools.map((c) => ({
316
+ type: 'tool_result',
317
+ tool_use_id: c.id,
318
+ content: 'Tool call limit reached. You must produce your final review verdict now.',
319
+ }));
320
+ messages.push({ role: 'assistant', content: body.content });
321
+ messages.push({ role: 'user', content: stopResults });
322
+ body = await this.apiCallWithRetry(systemBlocks, messages, tools);
323
+ accumulateUsage(body);
324
+ conversationTurns.push({ role: 'user', content: stopResults });
325
+ conversationTurns.push({ role: 'assistant', content: body.content });
326
+ }
327
+ }
328
+ const usage = buildUsage();
329
+ const costStr = usage.estimated_cost_usd !== null ? ` (~$${usage.estimated_cost_usd.toFixed(4)})` : '';
330
+ const cacheParts = [];
331
+ if (usage.cached_input_tokens)
332
+ cacheParts.push(`cache_read: ${usage.cached_input_tokens}`);
333
+ if (usage.cache_creation_input_tokens)
334
+ cacheParts.push(`cache_write: ${usage.cache_creation_input_tokens}`);
335
+ const cachedStr = cacheParts.length ? ` [${cacheParts.join(', ')}]` : '';
336
+ console.error(`[duul] Token usage: ${usage.input_tokens} in + ${usage.output_tokens} out = ${usage.total_tokens} total (${usage.api_calls} API calls)${cachedStr}${costStr}`);
337
+ // Extract text content and parse JSON
338
+ const text = body.content.find((c) => c.type === 'text')?.text;
339
+ if (!text) {
340
+ if (options.createFallback) {
341
+ const reason = body.stop_reason === 'tool_use' ? 'round_limit' : 'budget';
342
+ const fallback = options.createFallback(reason, allUsedTools);
343
+ console.error(`[duul] Returning structured fallback (reason: ${reason}).`);
344
+ return { parsed: fallback, reviewId: body.id, usage, conversationTurns };
345
+ }
346
+ throw new Error('Anthropic returned no text content');
347
+ }
348
+ const jsonStr = extractJson(text);
349
+ const parsed = outputSchema.parse(JSON.parse(jsonStr));
350
+ return { parsed, reviewId: body.id, usage, conversationTurns };
351
+ }
352
+ async apiCallWithRetry(system, messages, tools) {
353
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
354
+ const controller = new AbortController();
355
+ const timeout = setTimeout(() => controller.abort(), 120_000);
356
+ try {
357
+ const response = await fetch(`${this.baseUrl}/v1/messages`, {
358
+ method: 'POST',
359
+ headers: {
360
+ 'Content-Type': 'application/json',
361
+ 'x-api-key': this.apiKey,
362
+ 'anthropic-version': '2023-06-01',
363
+ },
364
+ body: JSON.stringify({
365
+ model: this.model,
366
+ max_tokens: 16384,
367
+ temperature: this.temperature,
368
+ top_p: this.topP,
369
+ system,
370
+ messages,
371
+ ...(tools ? { tools } : {}),
372
+ }),
373
+ signal: controller.signal,
374
+ });
375
+ clearTimeout(timeout);
376
+ if (!response.ok) {
377
+ const status = response.status;
378
+ if ((status === 429 || status >= 500) && attempt < MAX_RETRIES - 1) {
379
+ const delay = 1000 * Math.pow(2, attempt);
380
+ console.error(`[duul] Anthropic retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (status ${status})`);
381
+ await new Promise((resolve) => setTimeout(resolve, delay));
382
+ continue;
383
+ }
384
+ throw new Error(`Anthropic API error: ${status} ${response.statusText}`);
385
+ }
386
+ return await response.json();
387
+ }
388
+ catch (error) {
389
+ clearTimeout(timeout);
390
+ if (attempt < MAX_RETRIES - 1 && error instanceof Error && error.name === 'AbortError') {
391
+ const delay = 1000 * Math.pow(2, attempt);
392
+ console.error(`[duul] Anthropic retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms (timeout)`);
393
+ await new Promise((resolve) => setTimeout(resolve, delay));
394
+ continue;
395
+ }
396
+ throw error;
397
+ }
398
+ }
399
+ throw new Error('Unreachable: exhausted retries');
400
+ }
401
+ }
402
+ /**
403
+ * Minimal zod-to-JSON-schema conversion for the output format instruction.
404
+ */
405
+ function zodToJsonSchema(schema) {
406
+ if ('_def' in schema && typeof schema._def === 'object') {
407
+ const def = schema._def;
408
+ if (def.typeName === 'ZodObject' && def.shape) {
409
+ const shape = def.shape();
410
+ const properties = {};
411
+ for (const [key, value] of Object.entries(shape)) {
412
+ const zodField = value;
413
+ properties[key] = { description: zodField._def?.description ?? key };
414
+ }
415
+ return { type: 'object', properties };
416
+ }
417
+ }
418
+ return { type: 'object', description: 'See system prompt for schema details' };
419
+ }
420
+ /**
421
+ * Extract JSON from a string that may contain markdown code blocks.
422
+ */
423
+ function extractJson(text) {
424
+ const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
425
+ if (codeBlockMatch)
426
+ return codeBlockMatch[1].trim();
427
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
428
+ if (jsonMatch)
429
+ return jsonMatch[0];
430
+ return text.trim();
431
+ }
@@ -0,0 +1,28 @@
1
+ import type { z } from 'zod';
2
+ import type { ReviewerProvider, ReviewCallOptions, ReviewCallResult, ProviderCapabilities } from './types.js';
3
+ /**
4
+ * Google provider — uses Gemini models via the Generative Language API.
5
+ *
6
+ * Capabilities:
7
+ * - Tool calling via native functionDeclarations
8
+ * - JSON mode (responseMimeType: "application/json") when no tools active
9
+ * - No previous_response_id
10
+ */
11
+ export declare class GoogleProvider implements ReviewerProvider {
12
+ readonly name = "google";
13
+ readonly capabilities: ProviderCapabilities;
14
+ private apiKey;
15
+ private baseUrl;
16
+ private model;
17
+ private temperature;
18
+ private topP;
19
+ constructor(config?: {
20
+ apiKey?: string;
21
+ baseUrl?: string;
22
+ model?: string;
23
+ temperature?: number;
24
+ topP?: number;
25
+ });
26
+ review<T extends z.ZodType>(options: ReviewCallOptions<T>): Promise<ReviewCallResult<z.infer<T>>>;
27
+ private apiCallWithRetry;
28
+ }