ashlrcode 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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/package.json +46 -0
- package/src/__tests__/branded-types.test.ts +47 -0
- package/src/__tests__/context.test.ts +163 -0
- package/src/__tests__/cost-tracker.test.ts +274 -0
- package/src/__tests__/cron.test.ts +197 -0
- package/src/__tests__/dream.test.ts +204 -0
- package/src/__tests__/error-handler.test.ts +192 -0
- package/src/__tests__/features.test.ts +69 -0
- package/src/__tests__/file-history.test.ts +177 -0
- package/src/__tests__/hooks.test.ts +145 -0
- package/src/__tests__/keybindings.test.ts +159 -0
- package/src/__tests__/model-patches.test.ts +82 -0
- package/src/__tests__/permissions-rules.test.ts +121 -0
- package/src/__tests__/permissions.test.ts +108 -0
- package/src/__tests__/project-config.test.ts +63 -0
- package/src/__tests__/retry.test.ts +321 -0
- package/src/__tests__/router.test.ts +158 -0
- package/src/__tests__/session-compact.test.ts +191 -0
- package/src/__tests__/session.test.ts +145 -0
- package/src/__tests__/skill-registry.test.ts +130 -0
- package/src/__tests__/speculation.test.ts +196 -0
- package/src/__tests__/tasks-v2.test.ts +267 -0
- package/src/__tests__/telemetry.test.ts +149 -0
- package/src/__tests__/tool-executor.test.ts +141 -0
- package/src/__tests__/tool-registry.test.ts +166 -0
- package/src/__tests__/undercover.test.ts +93 -0
- package/src/__tests__/workflow.test.ts +195 -0
- package/src/agent/async-context.ts +64 -0
- package/src/agent/context.ts +245 -0
- package/src/agent/cron.ts +189 -0
- package/src/agent/dream.ts +165 -0
- package/src/agent/error-handler.ts +108 -0
- package/src/agent/ipc.ts +256 -0
- package/src/agent/kairos.ts +207 -0
- package/src/agent/loop.ts +314 -0
- package/src/agent/model-patches.ts +68 -0
- package/src/agent/speculation.ts +219 -0
- package/src/agent/sub-agent.ts +125 -0
- package/src/agent/system-prompt.ts +231 -0
- package/src/agent/team.ts +220 -0
- package/src/agent/tool-executor.ts +162 -0
- package/src/agent/workflow.ts +189 -0
- package/src/agent/worktree-manager.ts +86 -0
- package/src/autopilot/queue.ts +186 -0
- package/src/autopilot/scanner.ts +245 -0
- package/src/autopilot/types.ts +58 -0
- package/src/bridge/bridge-client.ts +57 -0
- package/src/bridge/bridge-server.ts +81 -0
- package/src/cli.ts +1120 -0
- package/src/config/features.ts +51 -0
- package/src/config/git.ts +137 -0
- package/src/config/hooks.ts +201 -0
- package/src/config/permissions.ts +251 -0
- package/src/config/project-config.ts +63 -0
- package/src/config/remote-settings.ts +163 -0
- package/src/config/settings-sync.ts +170 -0
- package/src/config/settings.ts +113 -0
- package/src/config/undercover.ts +76 -0
- package/src/config/upgrade-notice.ts +65 -0
- package/src/mcp/client.ts +197 -0
- package/src/mcp/manager.ts +125 -0
- package/src/mcp/oauth.ts +252 -0
- package/src/mcp/types.ts +61 -0
- package/src/persistence/memory.ts +129 -0
- package/src/persistence/session.ts +289 -0
- package/src/planning/plan-mode.ts +128 -0
- package/src/planning/plan-tools.ts +138 -0
- package/src/providers/anthropic.ts +177 -0
- package/src/providers/cost-tracker.ts +184 -0
- package/src/providers/retry.ts +264 -0
- package/src/providers/router.ts +159 -0
- package/src/providers/types.ts +79 -0
- package/src/providers/xai.ts +217 -0
- package/src/repl.tsx +1384 -0
- package/src/setup.ts +119 -0
- package/src/skills/loader.ts +78 -0
- package/src/skills/registry.ts +78 -0
- package/src/skills/types.ts +11 -0
- package/src/state/file-history.ts +264 -0
- package/src/telemetry/event-log.ts +116 -0
- package/src/tools/agent.ts +133 -0
- package/src/tools/ask-user.ts +229 -0
- package/src/tools/bash.ts +146 -0
- package/src/tools/config.ts +147 -0
- package/src/tools/diff.ts +137 -0
- package/src/tools/file-edit.ts +123 -0
- package/src/tools/file-read.ts +82 -0
- package/src/tools/file-write.ts +82 -0
- package/src/tools/glob.ts +76 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/ls.ts +77 -0
- package/src/tools/lsp.ts +375 -0
- package/src/tools/mcp-resources.ts +83 -0
- package/src/tools/mcp-tool.ts +47 -0
- package/src/tools/memory.ts +148 -0
- package/src/tools/notebook-edit.ts +133 -0
- package/src/tools/peers.ts +113 -0
- package/src/tools/powershell.ts +83 -0
- package/src/tools/registry.ts +114 -0
- package/src/tools/send-message.ts +75 -0
- package/src/tools/sleep.ts +50 -0
- package/src/tools/snip.ts +143 -0
- package/src/tools/tasks.ts +349 -0
- package/src/tools/team.ts +309 -0
- package/src/tools/todo-write.ts +93 -0
- package/src/tools/tool-search.ts +83 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/web-browser.ts +263 -0
- package/src/tools/web-fetch.ts +118 -0
- package/src/tools/web-search.ts +107 -0
- package/src/tools/workflow.ts +188 -0
- package/src/tools/worktree.ts +143 -0
- package/src/types/branded.ts +22 -0
- package/src/ui/App.tsx +184 -0
- package/src/ui/BuddyPanel.tsx +52 -0
- package/src/ui/PermissionPrompt.tsx +29 -0
- package/src/ui/banner.ts +217 -0
- package/src/ui/buddy-ai.ts +108 -0
- package/src/ui/buddy.ts +466 -0
- package/src/ui/context-bar.ts +60 -0
- package/src/ui/effort.ts +65 -0
- package/src/ui/keybindings.ts +143 -0
- package/src/ui/markdown.ts +271 -0
- package/src/ui/message-renderer.ts +73 -0
- package/src/ui/mode.ts +80 -0
- package/src/ui/notifications.ts +57 -0
- package/src/ui/speech-bubble.ts +95 -0
- package/src/ui/spinner.ts +116 -0
- package/src/ui/theme.ts +98 -0
- package/src/version.ts +5 -0
- package/src/voice/voice-mode.ts +169 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Claude provider — uses the official Anthropic SDK.
|
|
3
|
+
* Fallback provider for when Claude API access is available.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
7
|
+
import type {
|
|
8
|
+
Provider,
|
|
9
|
+
ProviderConfig,
|
|
10
|
+
ProviderRequest,
|
|
11
|
+
StreamEvent,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
import { withRetry } from "./retry.ts";
|
|
14
|
+
|
|
15
|
+
export function createAnthropicProvider(config: ProviderConfig): Provider {
|
|
16
|
+
const client = new Anthropic({
|
|
17
|
+
apiKey: config.apiKey,
|
|
18
|
+
...(config.baseURL ? { baseURL: config.baseURL } : {}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
name: "anthropic",
|
|
23
|
+
config,
|
|
24
|
+
pricing: [3.0, 15.0], // Sonnet 4.6 per million tokens
|
|
25
|
+
|
|
26
|
+
async *stream(request: ProviderRequest): AsyncGenerator<StreamEvent> {
|
|
27
|
+
const tools: Anthropic.Tool[] = request.tools.map((t) => ({
|
|
28
|
+
name: t.name,
|
|
29
|
+
description: t.description,
|
|
30
|
+
input_schema: t.input_schema as Anthropic.Tool.InputSchema,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const messages: Anthropic.MessageParam[] = request.messages.map(
|
|
34
|
+
convertMessage
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Use withRetry to create the stream, then iterate it.
|
|
38
|
+
// Anthropic SDK errors can surface during both creation and iteration,
|
|
39
|
+
// so we wrap the entire stream lifecycle in a retry-able unit.
|
|
40
|
+
const createAndConsumeStream = async (): Promise<StreamEvent[]> => {
|
|
41
|
+
const events: StreamEvent[] = [];
|
|
42
|
+
const stream = client.messages.stream({
|
|
43
|
+
model: config.model,
|
|
44
|
+
system: request.systemPrompt,
|
|
45
|
+
messages,
|
|
46
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
47
|
+
max_tokens: request.maxTokens ?? config.maxTokens ?? 8192,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
for await (const event of stream) {
|
|
51
|
+
switch (event.type) {
|
|
52
|
+
case "content_block_delta": {
|
|
53
|
+
const delta = event.delta;
|
|
54
|
+
if (delta.type === "text_delta") {
|
|
55
|
+
events.push({ type: "text_delta", text: delta.text });
|
|
56
|
+
} else if (delta.type === "input_json_delta") {
|
|
57
|
+
events.push({
|
|
58
|
+
type: "tool_call_delta",
|
|
59
|
+
text: delta.partial_json,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case "content_block_start": {
|
|
66
|
+
const block = event.content_block;
|
|
67
|
+
if (block.type === "tool_use") {
|
|
68
|
+
events.push({
|
|
69
|
+
type: "tool_call_start",
|
|
70
|
+
toolCall: { id: block.id, name: block.name },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "content_block_stop":
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
case "message_delta": {
|
|
80
|
+
const delta = event.delta;
|
|
81
|
+
events.push({
|
|
82
|
+
type: "message_end",
|
|
83
|
+
stopReason:
|
|
84
|
+
delta.stop_reason === "tool_use"
|
|
85
|
+
? "tool_use"
|
|
86
|
+
: delta.stop_reason === "max_tokens"
|
|
87
|
+
? "max_tokens"
|
|
88
|
+
: "end_turn",
|
|
89
|
+
usage: {
|
|
90
|
+
inputTokens: 0,
|
|
91
|
+
outputTokens: event.usage?.output_tokens ?? 0,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case "message_start": {
|
|
98
|
+
if (event.message.usage) {
|
|
99
|
+
events.push({
|
|
100
|
+
type: "usage",
|
|
101
|
+
usage: {
|
|
102
|
+
inputTokens: event.message.usage.input_tokens,
|
|
103
|
+
outputTokens: event.message.usage.output_tokens,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Extract completed tool calls from the final message
|
|
113
|
+
const finalMessage = await stream.finalMessage();
|
|
114
|
+
for (const block of finalMessage.content) {
|
|
115
|
+
if (block.type === "tool_use") {
|
|
116
|
+
events.push({
|
|
117
|
+
type: "tool_call_end",
|
|
118
|
+
toolCall: {
|
|
119
|
+
id: block.id,
|
|
120
|
+
name: block.name,
|
|
121
|
+
input: block.input as Record<string, unknown>,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return events;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const events = await withRetry(createAndConsumeStream, {
|
|
131
|
+
providerName: "anthropic",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
for (const event of events) {
|
|
135
|
+
yield event;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function convertMessage(
|
|
142
|
+
msg: ProviderRequest["messages"][number]
|
|
143
|
+
): Anthropic.MessageParam {
|
|
144
|
+
if (typeof msg.content === "string") {
|
|
145
|
+
return { role: msg.role === "tool" ? "user" : msg.role, content: msg.content };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const blocks: Anthropic.ContentBlockParam[] = msg.content
|
|
149
|
+
.filter((b) => b.type !== "image_url") // Anthropic uses different image format
|
|
150
|
+
.map((b) => {
|
|
151
|
+
switch (b.type) {
|
|
152
|
+
case "text":
|
|
153
|
+
return { type: "text" as const, text: b.text };
|
|
154
|
+
case "tool_use":
|
|
155
|
+
return {
|
|
156
|
+
type: "tool_use" as const,
|
|
157
|
+
id: b.id,
|
|
158
|
+
name: b.name,
|
|
159
|
+
input: b.input,
|
|
160
|
+
};
|
|
161
|
+
case "tool_result":
|
|
162
|
+
return {
|
|
163
|
+
type: "tool_result" as const,
|
|
164
|
+
tool_use_id: b.tool_use_id,
|
|
165
|
+
content: b.content,
|
|
166
|
+
is_error: b.is_error,
|
|
167
|
+
};
|
|
168
|
+
default:
|
|
169
|
+
return { type: "text" as const, text: "" };
|
|
170
|
+
}
|
|
171
|
+
}).filter(b => b.type !== "text" || b.text !== "");
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
role: msg.role === "tool" ? "user" : msg.role,
|
|
175
|
+
content: blocks,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detailed cost tracking — per-provider, per-model pricing with reasoning tokens.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
interface ModelPricing {
|
|
6
|
+
inputPerMillion: number;
|
|
7
|
+
outputPerMillion: number;
|
|
8
|
+
reasoningPerMillion?: number; // Some models charge extra for reasoning tokens
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Pricing tables (USD per million tokens)
|
|
12
|
+
const PRICING: Record<string, ModelPricing> = {
|
|
13
|
+
// xAI
|
|
14
|
+
"grok-4-1-fast-reasoning": {
|
|
15
|
+
inputPerMillion: 0.2,
|
|
16
|
+
outputPerMillion: 0.5,
|
|
17
|
+
},
|
|
18
|
+
"grok-4-0314": { inputPerMillion: 2.0, outputPerMillion: 4.0 },
|
|
19
|
+
"grok-3-fast": { inputPerMillion: 0.1, outputPerMillion: 0.3 },
|
|
20
|
+
// Anthropic
|
|
21
|
+
"claude-opus-4-6-20250514": {
|
|
22
|
+
inputPerMillion: 15.0,
|
|
23
|
+
outputPerMillion: 75.0,
|
|
24
|
+
},
|
|
25
|
+
"claude-sonnet-4-6-20250514": {
|
|
26
|
+
inputPerMillion: 3.0,
|
|
27
|
+
outputPerMillion: 15.0,
|
|
28
|
+
},
|
|
29
|
+
"claude-haiku-4-5-20251001": {
|
|
30
|
+
inputPerMillion: 0.8,
|
|
31
|
+
outputPerMillion: 4.0,
|
|
32
|
+
},
|
|
33
|
+
// OpenAI
|
|
34
|
+
"gpt-4o": { inputPerMillion: 2.5, outputPerMillion: 10.0 },
|
|
35
|
+
"gpt-4o-mini": { inputPerMillion: 0.15, outputPerMillion: 0.6 },
|
|
36
|
+
o1: {
|
|
37
|
+
inputPerMillion: 15.0,
|
|
38
|
+
outputPerMillion: 60.0,
|
|
39
|
+
reasoningPerMillion: 60.0,
|
|
40
|
+
},
|
|
41
|
+
"o1-mini": {
|
|
42
|
+
inputPerMillion: 3.0,
|
|
43
|
+
outputPerMillion: 12.0,
|
|
44
|
+
reasoningPerMillion: 12.0,
|
|
45
|
+
},
|
|
46
|
+
// DeepSeek
|
|
47
|
+
"deepseek-chat": { inputPerMillion: 0.14, outputPerMillion: 0.28 },
|
|
48
|
+
"deepseek-reasoner": {
|
|
49
|
+
inputPerMillion: 0.55,
|
|
50
|
+
outputPerMillion: 2.19,
|
|
51
|
+
reasoningPerMillion: 2.19,
|
|
52
|
+
},
|
|
53
|
+
// Groq (free tier, nominal costs)
|
|
54
|
+
"llama-3.3-70b-versatile": { inputPerMillion: 0.0, outputPerMillion: 0.0 },
|
|
55
|
+
// Local (Ollama)
|
|
56
|
+
"llama3.2": { inputPerMillion: 0.0, outputPerMillion: 0.0 },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function getPricing(model: string): ModelPricing {
|
|
60
|
+
// Exact match first
|
|
61
|
+
if (PRICING[model]) return PRICING[model];
|
|
62
|
+
// Partial match (model might have date suffix)
|
|
63
|
+
for (const [key, pricing] of Object.entries(PRICING)) {
|
|
64
|
+
if (model.startsWith(key) || key.startsWith(model)) return pricing;
|
|
65
|
+
}
|
|
66
|
+
// Unknown model — assume moderate pricing
|
|
67
|
+
return { inputPerMillion: 1.0, outputPerMillion: 3.0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TokenUsageTotals {
|
|
71
|
+
inputTokens: number;
|
|
72
|
+
outputTokens: number;
|
|
73
|
+
reasoningTokens: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ProviderCostEntry {
|
|
77
|
+
provider: string;
|
|
78
|
+
model: string;
|
|
79
|
+
usage: TokenUsageTotals;
|
|
80
|
+
costUSD: number;
|
|
81
|
+
calls: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class CostTracker {
|
|
85
|
+
private entries = new Map<string, ProviderCostEntry>();
|
|
86
|
+
|
|
87
|
+
/** Record token usage for a single API call */
|
|
88
|
+
record(
|
|
89
|
+
provider: string,
|
|
90
|
+
model: string,
|
|
91
|
+
usage: Partial<TokenUsageTotals>
|
|
92
|
+
): void {
|
|
93
|
+
const key = `${provider}:${model}`;
|
|
94
|
+
const existing = this.entries.get(key) ?? {
|
|
95
|
+
provider,
|
|
96
|
+
model,
|
|
97
|
+
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 },
|
|
98
|
+
costUSD: 0,
|
|
99
|
+
calls: 0,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
existing.usage.inputTokens += usage.inputTokens ?? 0;
|
|
103
|
+
existing.usage.outputTokens += usage.outputTokens ?? 0;
|
|
104
|
+
existing.usage.reasoningTokens += usage.reasoningTokens ?? 0;
|
|
105
|
+
existing.calls++;
|
|
106
|
+
|
|
107
|
+
// Recalculate cost from totals to avoid floating-point drift
|
|
108
|
+
const pricing = getPricing(model);
|
|
109
|
+
existing.costUSD =
|
|
110
|
+
(existing.usage.inputTokens / 1_000_000) * pricing.inputPerMillion +
|
|
111
|
+
(existing.usage.outputTokens / 1_000_000) * pricing.outputPerMillion +
|
|
112
|
+
(existing.usage.reasoningTokens / 1_000_000) *
|
|
113
|
+
(pricing.reasoningPerMillion ?? pricing.outputPerMillion);
|
|
114
|
+
|
|
115
|
+
this.entries.set(key, existing);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get total cost across all providers */
|
|
119
|
+
get totalCostUSD(): number {
|
|
120
|
+
let total = 0;
|
|
121
|
+
for (const entry of this.entries.values()) total += entry.costUSD;
|
|
122
|
+
return total;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Get total tokens */
|
|
126
|
+
get totalTokens(): TokenUsageTotals {
|
|
127
|
+
const total: TokenUsageTotals = {
|
|
128
|
+
inputTokens: 0,
|
|
129
|
+
outputTokens: 0,
|
|
130
|
+
reasoningTokens: 0,
|
|
131
|
+
};
|
|
132
|
+
for (const entry of this.entries.values()) {
|
|
133
|
+
total.inputTokens += entry.usage.inputTokens;
|
|
134
|
+
total.outputTokens += entry.usage.outputTokens;
|
|
135
|
+
total.reasoningTokens += entry.usage.reasoningTokens;
|
|
136
|
+
}
|
|
137
|
+
return total;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Get per-provider breakdown */
|
|
141
|
+
getBreakdown(): ProviderCostEntry[] {
|
|
142
|
+
return Array.from(this.entries.values());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Format a detailed cost summary */
|
|
146
|
+
formatSummary(): string {
|
|
147
|
+
const total = this.totalTokens;
|
|
148
|
+
const entries = this.getBreakdown();
|
|
149
|
+
|
|
150
|
+
const lines: string[] = [`Cost: $${this.totalCostUSD.toFixed(4)}`];
|
|
151
|
+
lines.push(
|
|
152
|
+
`Tokens: ${formatTokens(total.inputTokens)} in / ${formatTokens(total.outputTokens)} out${total.reasoningTokens > 0 ? ` / ${formatTokens(total.reasoningTokens)} reasoning` : ""}`
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (entries.length > 1) {
|
|
156
|
+
lines.push("Per provider:");
|
|
157
|
+
for (const e of entries) {
|
|
158
|
+
lines.push(
|
|
159
|
+
` ${e.provider}:${e.model} — $${e.costUSD.toFixed(4)} (${e.calls} calls)`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Legacy compat: expose the same shape the old CostTracker interface had ──
|
|
168
|
+
|
|
169
|
+
get totalInputTokens(): number {
|
|
170
|
+
return this.totalTokens.inputTokens;
|
|
171
|
+
}
|
|
172
|
+
get totalOutputTokens(): number {
|
|
173
|
+
return this.totalTokens.outputTokens;
|
|
174
|
+
}
|
|
175
|
+
get totalReasoningTokens(): number {
|
|
176
|
+
return this.totalTokens.reasoningTokens;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatTokens(n: number): string {
|
|
181
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
182
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
183
|
+
return `${n}`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry logic — exponential backoff with jitter for API calls.
|
|
3
|
+
* Circuit breaker pattern to stop hammering a failing provider.
|
|
4
|
+
*
|
|
5
|
+
* Centralizes retry behavior previously duplicated across xai.ts and anthropic.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { categorizeError, type CategorizedError } from "../agent/error-handler.ts";
|
|
9
|
+
|
|
10
|
+
// ── Retry Config ──────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface RetryConfig {
|
|
13
|
+
/** Max retry attempts per error category */
|
|
14
|
+
maxRetriesRateLimit: number;
|
|
15
|
+
maxRetriesNetwork: number;
|
|
16
|
+
maxRetriesServer: number;
|
|
17
|
+
/** Base delay in ms before first retry (doubles each attempt) */
|
|
18
|
+
baseDelayMs: number;
|
|
19
|
+
/** Absolute max delay cap */
|
|
20
|
+
maxDelayMs: number;
|
|
21
|
+
/** Provider name for log messages */
|
|
22
|
+
providerName: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CONFIG: RetryConfig = {
|
|
26
|
+
maxRetriesRateLimit: 3,
|
|
27
|
+
maxRetriesNetwork: 2,
|
|
28
|
+
maxRetriesServer: 2,
|
|
29
|
+
baseDelayMs: 1000,
|
|
30
|
+
maxDelayMs: 30_000,
|
|
31
|
+
providerName: "provider",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Retry Error ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export class RetryError extends Error {
|
|
37
|
+
constructor(
|
|
38
|
+
message: string,
|
|
39
|
+
public readonly attempts: number,
|
|
40
|
+
public readonly lastCategory?: string,
|
|
41
|
+
public readonly lastStatus?: number,
|
|
42
|
+
) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "RetryError";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Core retry function ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Execute a function with category-aware exponential backoff and jitter.
|
|
52
|
+
*
|
|
53
|
+
* - Rate limit (429): up to maxRetriesRateLimit retries
|
|
54
|
+
* - Network errors: up to maxRetriesNetwork retries
|
|
55
|
+
* - Server errors (500/502/503/504/529): up to maxRetriesServer retries
|
|
56
|
+
* - Auth errors (401/403): fail immediately
|
|
57
|
+
* - Unknown non-retryable errors: fail immediately
|
|
58
|
+
*/
|
|
59
|
+
export async function withRetry<T>(
|
|
60
|
+
fn: () => Promise<T>,
|
|
61
|
+
config: Partial<RetryConfig> = {},
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
64
|
+
let lastError: Error | null = null;
|
|
65
|
+
let lastCategorized: CategorizedError | null = null;
|
|
66
|
+
|
|
67
|
+
for (let attempt = 0; ; attempt++) {
|
|
68
|
+
try {
|
|
69
|
+
return await fn();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
72
|
+
lastCategorized = categorizeError(lastError);
|
|
73
|
+
|
|
74
|
+
// Auth errors: fail immediately with clear message
|
|
75
|
+
if (lastCategorized.category === "auth") {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`[${cfg.providerName}] Authentication failed — check your API key. (${lastError.message})`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Determine max retries for this error category
|
|
82
|
+
const maxRetries = getMaxRetries(lastCategorized, cfg);
|
|
83
|
+
|
|
84
|
+
if (!lastCategorized.retryable || attempt >= maxRetries) {
|
|
85
|
+
throw lastError;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Calculate delay with exponential backoff + jitter
|
|
89
|
+
const delay = calculateDelay(attempt, lastCategorized, cfg);
|
|
90
|
+
|
|
91
|
+
process.stderr.write(
|
|
92
|
+
`[${cfg.providerName}] ${lastCategorized.category} error, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...\n`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await sleep(delay);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wrap an async generator with retry — recreates the generator on failure.
|
|
102
|
+
* Used for streaming API calls where errors can surface mid-iteration.
|
|
103
|
+
*/
|
|
104
|
+
export async function* withStreamRetry<T>(
|
|
105
|
+
createStream: () => AsyncGenerator<T>,
|
|
106
|
+
config: Partial<RetryConfig> = {},
|
|
107
|
+
): AsyncGenerator<T> {
|
|
108
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
109
|
+
let lastError: Error | null = null;
|
|
110
|
+
|
|
111
|
+
for (let attempt = 0; ; attempt++) {
|
|
112
|
+
const stream = createStream();
|
|
113
|
+
try {
|
|
114
|
+
for await (const event of stream) {
|
|
115
|
+
yield event;
|
|
116
|
+
}
|
|
117
|
+
return; // Stream completed successfully
|
|
118
|
+
} catch (err) {
|
|
119
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
120
|
+
const categorized = categorizeError(lastError);
|
|
121
|
+
|
|
122
|
+
// Auth: fail fast
|
|
123
|
+
if (categorized.category === "auth") {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`[${cfg.providerName}] Authentication failed — check your API key. (${lastError.message})`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const maxRetries = getMaxRetries(categorized, cfg);
|
|
130
|
+
|
|
131
|
+
if (!categorized.retryable || attempt >= maxRetries) {
|
|
132
|
+
throw lastError;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const delay = calculateDelay(attempt, categorized, cfg);
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
`[${cfg.providerName}] ${categorized.category} error mid-stream, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})...\n`,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await sleep(delay);
|
|
141
|
+
// Loop re-creates the stream from scratch
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Circuit Breaker ───────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export type CircuitState = "closed" | "open" | "half-open";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Circuit breaker — stop hammering a provider after consecutive failures.
|
|
152
|
+
*
|
|
153
|
+
* States:
|
|
154
|
+
* - closed: requests flow normally
|
|
155
|
+
* - open: requests are blocked (too many failures)
|
|
156
|
+
* - half-open: one probe request allowed to test recovery
|
|
157
|
+
*/
|
|
158
|
+
export class CircuitBreaker {
|
|
159
|
+
private failures = 0;
|
|
160
|
+
private lastFailureTime = 0;
|
|
161
|
+
private state: CircuitState = "closed";
|
|
162
|
+
private probeInFlight = false;
|
|
163
|
+
|
|
164
|
+
constructor(
|
|
165
|
+
/** Number of consecutive failures before opening the circuit */
|
|
166
|
+
private readonly threshold: number = 5,
|
|
167
|
+
/** Time in ms before an open circuit transitions to half-open */
|
|
168
|
+
private readonly resetTimeMs: number = 60_000,
|
|
169
|
+
) {}
|
|
170
|
+
|
|
171
|
+
/** Check if a request should be allowed through */
|
|
172
|
+
canRequest(): boolean {
|
|
173
|
+
if (this.state === "closed") return true;
|
|
174
|
+
|
|
175
|
+
if (this.state === "open") {
|
|
176
|
+
// Check if enough time has passed to try again
|
|
177
|
+
if (Date.now() - this.lastFailureTime >= this.resetTimeMs) {
|
|
178
|
+
this.state = "half-open";
|
|
179
|
+
this.probeInFlight = true;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// half-open: allow only one probe request at a time
|
|
186
|
+
if (this.probeInFlight) return false;
|
|
187
|
+
this.probeInFlight = true;
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Record a successful request — resets failure count */
|
|
192
|
+
recordSuccess(): void {
|
|
193
|
+
this.failures = 0;
|
|
194
|
+
this.state = "closed";
|
|
195
|
+
this.probeInFlight = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Record a failed request — may trip the breaker */
|
|
199
|
+
recordFailure(): void {
|
|
200
|
+
this.probeInFlight = false;
|
|
201
|
+
this.failures++;
|
|
202
|
+
this.lastFailureTime = Date.now();
|
|
203
|
+
if (this.failures >= this.threshold) {
|
|
204
|
+
this.state = "open";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
getState(): CircuitState {
|
|
209
|
+
// Re-check for time-based transition
|
|
210
|
+
if (this.state === "open" && Date.now() - this.lastFailureTime >= this.resetTimeMs) {
|
|
211
|
+
this.state = "half-open";
|
|
212
|
+
}
|
|
213
|
+
return this.state;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getStatus(): string {
|
|
217
|
+
return `${this.getState()} (${this.failures}/${this.threshold} failures)`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function getMaxRetries(categorized: CategorizedError, cfg: RetryConfig): number {
|
|
224
|
+
switch (categorized.category) {
|
|
225
|
+
case "rate_limit":
|
|
226
|
+
return cfg.maxRetriesRateLimit;
|
|
227
|
+
case "network":
|
|
228
|
+
return cfg.maxRetriesNetwork;
|
|
229
|
+
default:
|
|
230
|
+
// Server errors (5xx) are retryable if categorized as such
|
|
231
|
+
return categorized.retryable ? cfg.maxRetriesServer : 0;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function calculateDelay(
|
|
236
|
+
attempt: number,
|
|
237
|
+
categorized: CategorizedError,
|
|
238
|
+
cfg: RetryConfig,
|
|
239
|
+
): number {
|
|
240
|
+
// If the error includes a Retry-After hint, use it
|
|
241
|
+
if (categorized.retryAfterMs && categorized.retryAfterMs > 0) {
|
|
242
|
+
return Math.min(categorized.retryAfterMs, cfg.maxDelayMs);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Rate limits get a higher base delay
|
|
246
|
+
const baseDelay =
|
|
247
|
+
categorized.category === "rate_limit"
|
|
248
|
+
? cfg.baseDelayMs
|
|
249
|
+
: categorized.category === "network"
|
|
250
|
+
? cfg.baseDelayMs * 2
|
|
251
|
+
: cfg.baseDelayMs;
|
|
252
|
+
|
|
253
|
+
// Exponential backoff: base * 2^attempt
|
|
254
|
+
const exponential = baseDelay * Math.pow(2, attempt);
|
|
255
|
+
|
|
256
|
+
// Add jitter: ±25% randomization to prevent thundering herd
|
|
257
|
+
const jitter = exponential * 0.25 * (Math.random() * 2 - 1);
|
|
258
|
+
|
|
259
|
+
return Math.min(Math.round(exponential + jitter), cfg.maxDelayMs);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sleep(ms: number): Promise<void> {
|
|
263
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
264
|
+
}
|