klyro 0.1.1

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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/agent/anthropic-adapter.d.ts +75 -0
  4. package/dist/agent/anthropic-adapter.js +297 -0
  5. package/dist/agent/message.d.ts +40 -0
  6. package/dist/agent/message.js +21 -0
  7. package/dist/agent/observation.d.ts +47 -0
  8. package/dist/agent/observation.js +53 -0
  9. package/dist/agent/provider-adapter.d.ts +101 -0
  10. package/dist/agent/provider-adapter.js +254 -0
  11. package/dist/agent/registry.d.ts +42 -0
  12. package/dist/agent/registry.js +86 -0
  13. package/dist/agent/retry.d.ts +34 -0
  14. package/dist/agent/retry.js +91 -0
  15. package/dist/agent/runtime.d.ts +130 -0
  16. package/dist/agent/runtime.js +221 -0
  17. package/dist/agent/worker-spawner.d.ts +41 -0
  18. package/dist/agent/worker-spawner.js +76 -0
  19. package/dist/chat.d.ts +39 -0
  20. package/dist/chat.js +235 -0
  21. package/dist/chat.js.map +1 -0
  22. package/dist/cli/eval.d.ts +75 -0
  23. package/dist/cli/eval.js +190 -0
  24. package/dist/cli/repl.d.ts +15 -0
  25. package/dist/cli/repl.js +194 -0
  26. package/dist/cli/run.d.ts +67 -0
  27. package/dist/cli/run.js +198 -0
  28. package/dist/cli/slash/parser.d.ts +39 -0
  29. package/dist/cli/slash/parser.js +47 -0
  30. package/dist/context/level6.d.ts +45 -0
  31. package/dist/context/level6.js +231 -0
  32. package/dist/context/level7.d.ts +84 -0
  33. package/dist/context/level7.js +156 -0
  34. package/dist/context/project-map.d.ts +48 -0
  35. package/dist/context/project-map.js +438 -0
  36. package/dist/context/repo-map.d.ts +30 -0
  37. package/dist/context/repo-map.js +168 -0
  38. package/dist/context/selector.d.ts +22 -0
  39. package/dist/context/selector.js +37 -0
  40. package/dist/context/snippets.d.ts +17 -0
  41. package/dist/context/snippets.js +15 -0
  42. package/dist/context/tokenizer.d.ts +49 -0
  43. package/dist/context/tokenizer.js +97 -0
  44. package/dist/eval/harness.d.ts +48 -0
  45. package/dist/eval/harness.js +149 -0
  46. package/dist/eval/tasks.d.ts +6 -0
  47. package/dist/eval/tasks.js +98 -0
  48. package/dist/index.d.ts +14 -0
  49. package/dist/index.js +128 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/persistence/audit.d.ts +75 -0
  52. package/dist/persistence/audit.js +15 -0
  53. package/dist/persistence/store.d.ts +67 -0
  54. package/dist/persistence/store.js +106 -0
  55. package/dist/policy/approval.d.ts +35 -0
  56. package/dist/policy/approval.js +54 -0
  57. package/dist/policy/engine.d.ts +76 -0
  58. package/dist/policy/engine.js +163 -0
  59. package/dist/policy/path-guard.d.ts +30 -0
  60. package/dist/policy/path-guard.js +100 -0
  61. package/dist/policy/secret-redactor.d.ts +19 -0
  62. package/dist/policy/secret-redactor.js +52 -0
  63. package/dist/providers.d.ts +23 -0
  64. package/dist/providers.js +83 -0
  65. package/dist/repl.d.ts +9 -0
  66. package/dist/repl.js +170 -0
  67. package/dist/repl.js.map +1 -0
  68. package/dist/tools/fs/edit-file.d.ts +48 -0
  69. package/dist/tools/fs/edit-file.js +84 -0
  70. package/dist/tools/fs/list-dir.d.ts +40 -0
  71. package/dist/tools/fs/list-dir.js +72 -0
  72. package/dist/tools/fs/read-file.d.ts +66 -0
  73. package/dist/tools/fs/read-file.js +75 -0
  74. package/dist/tools/fs/write-file.d.ts +24 -0
  75. package/dist/tools/fs/write-file.js +48 -0
  76. package/dist/tools/git/git-diff.d.ts +35 -0
  77. package/dist/tools/git/git-diff.js +68 -0
  78. package/dist/tools/git/git-status.d.ts +29 -0
  79. package/dist/tools/git/git-status.js +57 -0
  80. package/dist/tools/normalize.d.ts +31 -0
  81. package/dist/tools/normalize.js +88 -0
  82. package/dist/tools/registry.d.ts +23 -0
  83. package/dist/tools/registry.js +87 -0
  84. package/dist/tools/schema.d.ts +29 -0
  85. package/dist/tools/schema.js +183 -0
  86. package/dist/tools/search/dependencies.d.ts +44 -0
  87. package/dist/tools/search/dependencies.js +188 -0
  88. package/dist/tools/search/glob.d.ts +26 -0
  89. package/dist/tools/search/glob.js +88 -0
  90. package/dist/tools/search/grep.d.ts +52 -0
  91. package/dist/tools/search/grep.js +125 -0
  92. package/dist/tools/search/recent-files.d.ts +33 -0
  93. package/dist/tools/search/recent-files.js +100 -0
  94. package/dist/tools/search/search-files.d.ts +39 -0
  95. package/dist/tools/search/search-files.js +110 -0
  96. package/dist/tools/shell/shell-exec.d.ts +60 -0
  97. package/dist/tools/shell/shell-exec.js +133 -0
  98. package/dist/tools/types.d.ts +54 -0
  99. package/dist/tools/types.js +15 -0
  100. package/dist/tools/verify/run-verify.d.ts +60 -0
  101. package/dist/tools/verify/run-verify.js +115 -0
  102. package/dist/tui/app.d.ts +27 -0
  103. package/dist/tui/app.js +79 -0
  104. package/dist/tui/app.test.d.ts +1 -0
  105. package/dist/tui/app.test.js +77 -0
  106. package/dist/tui/status.d.ts +21 -0
  107. package/dist/tui/status.js +13 -0
  108. package/dist/tui/status.test.d.ts +1 -0
  109. package/dist/tui/status.test.js +39 -0
  110. package/dist/tui/transcript.d.ts +40 -0
  111. package/dist/tui/transcript.js +28 -0
  112. package/dist/tui/transcript.test.d.ts +1 -0
  113. package/dist/tui/transcript.test.js +71 -0
  114. package/dist/verification/detect.d.ts +29 -0
  115. package/dist/verification/detect.js +85 -0
  116. package/dist/verification/engine.d.ts +25 -0
  117. package/dist/verification/engine.js +57 -0
  118. package/package.json +58 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ProviderAdapter — a normalized streaming chat-completion interface
3
+ * that the agent runtime consumes.
4
+ *
5
+ * Implementations:
6
+ * - httpChatAdapter (default): OpenAI-compatible /v1/chat/completions
7
+ * with tool calls. Works against api.openai.com, Ollama, LM Studio,
8
+ * vLLM, llama.cpp, and any compatible proxy.
9
+ *
10
+ * The adapter exposes a single async generator of StreamEvents so the
11
+ * runtime loop sees one shape regardless of provider quirks.
12
+ */
13
+ import { z } from 'zod';
14
+ import type { Message } from './message.js';
15
+ export type StreamEvent = {
16
+ kind: 'text_delta';
17
+ text: string;
18
+ } | {
19
+ kind: 'message_start';
20
+ id?: string;
21
+ model?: string;
22
+ } | {
23
+ kind: 'message_end';
24
+ finishReason?: string;
25
+ usage?: {
26
+ input: number;
27
+ output: number;
28
+ };
29
+ } | {
30
+ kind: 'tool_call_start';
31
+ id: string;
32
+ name: string;
33
+ } | {
34
+ kind: 'tool_call_delta';
35
+ id: string;
36
+ argsJson: string;
37
+ } | {
38
+ kind: 'tool_call_end';
39
+ id: string;
40
+ } | {
41
+ kind: 'error';
42
+ code: string;
43
+ message: string;
44
+ retryable: boolean;
45
+ };
46
+ export interface ToolDefinition {
47
+ name: string;
48
+ description: string;
49
+ inputSchema: unknown;
50
+ }
51
+ export interface CallRequest {
52
+ model: string;
53
+ system?: string;
54
+ messages: Message[];
55
+ tools: ToolDefinition[];
56
+ maxTokens?: number;
57
+ temperature?: number;
58
+ signal?: AbortSignal;
59
+ }
60
+ export interface ProviderAdapter {
61
+ readonly id: string;
62
+ stream(req: CallRequest): AsyncIterable<StreamEvent>;
63
+ }
64
+ export interface HttpAdapterOptions {
65
+ baseURL: string;
66
+ apiKey: string;
67
+ /** Per-request timeout in ms. */
68
+ timeoutMs?: number;
69
+ /** Override fetch (e.g. for tests). */
70
+ fetchImpl?: typeof fetch;
71
+ }
72
+ /** Convert a Zod schema to a permissive JSON Schema object for tool defs. */
73
+ export declare function zodToJsonSchema(schema: z.ZodType<unknown>): Record<string, unknown>;
74
+ interface ChatCompletionsRequest {
75
+ model: string;
76
+ messages: Array<{
77
+ role: string;
78
+ content?: string;
79
+ tool_calls?: unknown[];
80
+ tool_call_id?: string;
81
+ name?: string;
82
+ }>;
83
+ tools?: Array<{
84
+ type: 'function';
85
+ function: {
86
+ name: string;
87
+ description: string;
88
+ parameters: unknown;
89
+ };
90
+ }>;
91
+ max_tokens?: number;
92
+ temperature?: number;
93
+ stream: true;
94
+ }
95
+ /**
96
+ * Build the OpenAI-compatible request body from our normalized CallRequest.
97
+ * Exported for testing.
98
+ */
99
+ export declare function buildChatCompletionsBody(req: CallRequest): ChatCompletionsRequest;
100
+ export declare function httpChatAdapter(opts: HttpAdapterOptions): ProviderAdapter;
101
+ export {};
@@ -0,0 +1,254 @@
1
+ /**
2
+ * ProviderAdapter — a normalized streaming chat-completion interface
3
+ * that the agent runtime consumes.
4
+ *
5
+ * Implementations:
6
+ * - httpChatAdapter (default): OpenAI-compatible /v1/chat/completions
7
+ * with tool calls. Works against api.openai.com, Ollama, LM Studio,
8
+ * vLLM, llama.cpp, and any compatible proxy.
9
+ *
10
+ * The adapter exposes a single async generator of StreamEvents so the
11
+ * runtime loop sees one shape regardless of provider quirks.
12
+ */
13
+ const DEFAULT_TIMEOUT_MS = 120_000;
14
+ /** Convert a Zod schema to a permissive JSON Schema object for tool defs. */
15
+ export function zodToJsonSchema(schema) {
16
+ // We keep this simple: zod's own _def is enough to give the model a
17
+ // shape. A full conversion library isn't needed for MVP — most tool
18
+ // inputs are flat objects with primitive types.
19
+ const def = schema._def;
20
+ if (!def)
21
+ return { type: 'object', properties: {}, additionalProperties: true };
22
+ if (def.typeName === 'ZodObject' && def.shape) {
23
+ const props = {};
24
+ const required = [];
25
+ for (const [k, v] of Object.entries(def.shape())) {
26
+ props[k] = zodFieldSchema(v);
27
+ if (!v.isOptional())
28
+ required.push(k);
29
+ }
30
+ const out = { type: 'object', properties: props };
31
+ if (required.length)
32
+ out.required = required;
33
+ out.additionalProperties = false;
34
+ return out;
35
+ }
36
+ return { type: 'object', properties: {}, additionalProperties: true };
37
+ }
38
+ function zodFieldSchema(s) {
39
+ const def = s._def;
40
+ const name = def?.typeName;
41
+ switch (name) {
42
+ case 'ZodString':
43
+ return { type: 'string' };
44
+ case 'ZodNumber':
45
+ return { type: 'number' };
46
+ case 'ZodBoolean':
47
+ return { type: 'boolean' };
48
+ case 'ZodArray': {
49
+ const inner = def?.innerType;
50
+ return { type: 'array', items: inner ? zodFieldSchema(inner) : { type: 'string' } };
51
+ }
52
+ case 'ZodOptional': {
53
+ const inner = def?.innerType;
54
+ return inner ? zodFieldSchema(inner) : { type: 'string' };
55
+ }
56
+ case 'ZodEnum': {
57
+ const values = s._def.values;
58
+ return { type: 'string', enum: [...values] };
59
+ }
60
+ case 'ZodObject':
61
+ return zodToJsonSchema(s);
62
+ default:
63
+ return { type: 'string' };
64
+ }
65
+ }
66
+ /**
67
+ * Build the OpenAI-compatible request body from our normalized CallRequest.
68
+ * Exported for testing.
69
+ */
70
+ export function buildChatCompletionsBody(req) {
71
+ const messages = [];
72
+ if (req.system)
73
+ messages.push({ role: 'system', content: req.system });
74
+ for (const m of req.messages) {
75
+ if (m.role === 'assistant') {
76
+ const text = m.content.filter((b) => b.kind === 'text').map((b) => b.text).join('');
77
+ const tcs = m.content
78
+ .filter((b) => b.kind === 'tool_use')
79
+ .map((b) => {
80
+ const tb = b;
81
+ return {
82
+ id: tb.id,
83
+ type: 'function',
84
+ function: { name: tb.name, arguments: JSON.stringify(tb.input) },
85
+ };
86
+ });
87
+ const msg = { role: 'assistant' };
88
+ if (text)
89
+ msg.content = text;
90
+ if (tcs.length)
91
+ msg.tool_calls = tcs;
92
+ messages.push(msg);
93
+ }
94
+ else if (m.role === 'tool') {
95
+ for (const b of m.content) {
96
+ if (b.kind === 'tool_result') {
97
+ const tr = b;
98
+ messages.push({
99
+ role: 'tool',
100
+ tool_call_id: tr.toolCallId,
101
+ name: tr.name,
102
+ content: typeof tr.output === 'string' ? tr.output : JSON.stringify(tr.output ?? ''),
103
+ });
104
+ }
105
+ }
106
+ }
107
+ else if (m.role === 'user') {
108
+ const text = m.content.filter((b) => b.kind === 'text').map((b) => b.text).join('');
109
+ messages.push({ role: 'user', content: text });
110
+ }
111
+ }
112
+ const body = { model: req.model, messages, stream: true };
113
+ if (req.maxTokens)
114
+ body.max_tokens = req.maxTokens;
115
+ if (typeof req.temperature === 'number')
116
+ body.temperature = req.temperature;
117
+ if (req.tools.length) {
118
+ body.tools = req.tools.map((t) => ({
119
+ type: 'function',
120
+ function: { name: t.name, description: t.description, parameters: t.inputSchema },
121
+ }));
122
+ }
123
+ return body;
124
+ }
125
+ export function httpChatAdapter(opts) {
126
+ const fetchImpl = opts.fetchImpl ?? fetch;
127
+ const url = `${opts.baseURL.replace(/\/$/, '')}/chat/completions`;
128
+ return {
129
+ id: 'http-chat',
130
+ stream(req) {
131
+ return streamChatCompletions(url, opts, req, fetchImpl);
132
+ },
133
+ };
134
+ }
135
+ async function* streamChatCompletions(url, opts, req, fetchImpl) {
136
+ const body = buildChatCompletionsBody(req);
137
+ const ac = new AbortController();
138
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
139
+ const timer = setTimeout(() => ac.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs);
140
+ const onAbort = () => ac.abort(req.signal?.reason);
141
+ req.signal?.addEventListener('abort', onAbort);
142
+ let res;
143
+ try {
144
+ res = await fetchImpl(url, {
145
+ method: 'POST',
146
+ headers: {
147
+ 'Content-Type': 'application/json',
148
+ ...(opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {}),
149
+ },
150
+ body: JSON.stringify(body),
151
+ signal: ac.signal,
152
+ });
153
+ }
154
+ catch (err) {
155
+ clearTimeout(timer);
156
+ req.signal?.removeEventListener('abort', onAbort);
157
+ const msg = err instanceof Error ? err.message : String(err);
158
+ yield { kind: 'error', code: 'NETWORK', message: msg, retryable: true };
159
+ return;
160
+ }
161
+ if (!res.ok || !res.body) {
162
+ clearTimeout(timer);
163
+ req.signal?.removeEventListener('abort', onAbort);
164
+ const errText = await res.text().catch(() => '');
165
+ yield {
166
+ kind: 'error',
167
+ code: `HTTP_${res.status}`,
168
+ message: `provider returned ${res.status}: ${errText.slice(0, 500)}`,
169
+ retryable: res.status >= 500 || res.status === 429,
170
+ };
171
+ return;
172
+ }
173
+ yield { kind: 'message_start' };
174
+ const reader = res.body.getReader();
175
+ const decoder = new TextDecoder();
176
+ let buf = '';
177
+ // Track per-tool-call id by index.
178
+ const toolIds = new Map();
179
+ const toolNames = new Map();
180
+ try {
181
+ while (true) {
182
+ const { value, done } = await reader.read();
183
+ if (done)
184
+ break;
185
+ buf += decoder.decode(value, { stream: true });
186
+ // SSE: events separated by \n\n
187
+ let idx;
188
+ while ((idx = buf.indexOf('\n\n')) !== -1) {
189
+ const event = buf.slice(0, idx);
190
+ buf = buf.slice(idx + 2);
191
+ for (const line of event.split('\n')) {
192
+ if (!line.startsWith('data:'))
193
+ continue;
194
+ const data = line.slice(5).trim();
195
+ if (data === '[DONE]') {
196
+ yield { kind: 'message_end' };
197
+ return;
198
+ }
199
+ let chunk;
200
+ try {
201
+ chunk = JSON.parse(data);
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ for (const choice of chunk.choices) {
207
+ if (choice.delta.content) {
208
+ yield { kind: 'text_delta', text: choice.delta.content };
209
+ }
210
+ for (const tc of choice.delta.tool_calls ?? []) {
211
+ if (tc.id && tc.function?.name) {
212
+ toolIds.set(tc.index, tc.id);
213
+ toolNames.set(tc.index, tc.function.name);
214
+ yield { kind: 'tool_call_start', id: tc.id, name: tc.function.name };
215
+ }
216
+ else if (tc.id) {
217
+ toolIds.set(tc.index, tc.id);
218
+ }
219
+ if (tc.function?.arguments) {
220
+ const id = toolIds.get(tc.index) ?? `call_${tc.index}`;
221
+ yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
222
+ }
223
+ }
224
+ if (choice.finish_reason) {
225
+ const usage = chunk.usage
226
+ ? { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens }
227
+ : undefined;
228
+ yield { kind: 'message_end', finishReason: choice.finish_reason, usage };
229
+ }
230
+ }
231
+ }
232
+ }
233
+ }
234
+ if (toolIds.size) {
235
+ for (const id of toolIds.values())
236
+ yield { kind: 'tool_call_end', id };
237
+ }
238
+ else {
239
+ yield { kind: 'message_end' };
240
+ }
241
+ }
242
+ catch (err) {
243
+ const msg = err instanceof Error ? err.message : String(err);
244
+ yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
245
+ }
246
+ finally {
247
+ clearTimeout(timer);
248
+ req.signal?.removeEventListener('abort', onAbort);
249
+ try {
250
+ reader.releaseLock();
251
+ }
252
+ catch { /* ignore */ }
253
+ }
254
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Provider registry — config-driven selection of ProviderAdapter.
3
+ *
4
+ * Two providers today:
5
+ * - "openai" → OpenAI-compatible /v1/chat/completions (works with
6
+ * OpenAI, Azure, Ollama, vLLM, LocalAI, OpenRouter,
7
+ * Anthropic's OpenAI-compat endpoint, etc.)
8
+ * - "anthropic" → Anthropic's native /v1/messages streaming API
9
+ *
10
+ * Selection precedence (later overrides earlier):
11
+ * 1. function arg (e.g. --provider CLI flag)
12
+ * 2. KLYRO_PROVIDER env var
13
+ * 3. inference from KLYRO_BASE_URL host (heuristic)
14
+ * 4. default: "openai"
15
+ *
16
+ * The result is wrapped in retryingAdapter by default. Pass
17
+ * { retry: false } to skip.
18
+ */
19
+ import type { ProviderAdapter } from './provider-adapter.js';
20
+ import { type RetryOptions } from './retry.js';
21
+ export type ProviderName = 'openai' | 'anthropic';
22
+ export interface BuildProviderOptions {
23
+ provider?: ProviderName;
24
+ baseURL?: string;
25
+ apiKey?: string;
26
+ timeoutMs?: number;
27
+ retry?: Partial<RetryOptions> | false;
28
+ }
29
+ export declare function inferProviderFromBaseURL(baseURL: string | undefined): ProviderName;
30
+ export declare function buildProvider(opts?: BuildProviderOptions): ProviderAdapter;
31
+ /**
32
+ * Build the provider used by `klyro run` / `klyro chat` — honors --provider
33
+ * flag, then KLYRO_PROVIDER env, then heuristic.
34
+ *
35
+ * This is the version called from CLI entry points.
36
+ */
37
+ export declare function buildProviderFromCli(args: {
38
+ provider?: string;
39
+ baseUrl?: string;
40
+ apiKey?: string;
41
+ timeoutMs?: number;
42
+ }): ProviderAdapter;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Provider registry — config-driven selection of ProviderAdapter.
3
+ *
4
+ * Two providers today:
5
+ * - "openai" → OpenAI-compatible /v1/chat/completions (works with
6
+ * OpenAI, Azure, Ollama, vLLM, LocalAI, OpenRouter,
7
+ * Anthropic's OpenAI-compat endpoint, etc.)
8
+ * - "anthropic" → Anthropic's native /v1/messages streaming API
9
+ *
10
+ * Selection precedence (later overrides earlier):
11
+ * 1. function arg (e.g. --provider CLI flag)
12
+ * 2. KLYRO_PROVIDER env var
13
+ * 3. inference from KLYRO_BASE_URL host (heuristic)
14
+ * 4. default: "openai"
15
+ *
16
+ * The result is wrapped in retryingAdapter by default. Pass
17
+ * { retry: false } to skip.
18
+ */
19
+ import { httpChatAdapter } from './provider-adapter.js';
20
+ import { anthropicAdapter } from './anthropic-adapter.js';
21
+ import { retryingAdapter } from './retry.js';
22
+ /** Heuristic: hosts that look like Anthropic's API. */
23
+ function looksLikeAnthropic(host) {
24
+ return /(^|\.)anthropic\.com$/i.test(host) || /anthropic/i.test(host);
25
+ }
26
+ export function inferProviderFromBaseURL(baseURL) {
27
+ if (!baseURL)
28
+ return 'openai';
29
+ try {
30
+ const u = new URL(baseURL);
31
+ return looksLikeAnthropic(u.hostname) ? 'anthropic' : 'openai';
32
+ }
33
+ catch {
34
+ return 'openai';
35
+ }
36
+ }
37
+ export function buildProvider(opts = {}) {
38
+ const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL;
39
+ const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY;
40
+ const timeoutMs = opts.timeoutMs ?? 60_000;
41
+ // Resolve provider.
42
+ let provider;
43
+ if (opts.provider) {
44
+ provider = opts.provider;
45
+ }
46
+ else if (process.env.KLYRO_PROVIDER === 'anthropic' || process.env.KLYRO_PROVIDER === 'openai') {
47
+ provider = process.env.KLYRO_PROVIDER;
48
+ }
49
+ else {
50
+ provider = inferProviderFromBaseURL(baseURL);
51
+ }
52
+ let inner;
53
+ if (provider === 'anthropic') {
54
+ if (!apiKey) {
55
+ throw new Error('buildProvider: anthropic provider requires an apiKey (or KLYRO_API_KEY).');
56
+ }
57
+ inner = anthropicAdapter({ baseURL, apiKey, timeoutMs });
58
+ }
59
+ else {
60
+ if (!baseURL || !apiKey) {
61
+ throw new Error('buildProvider: openai provider requires baseURL and apiKey (or KLYRO_BASE_URL + KLYRO_API_KEY).');
62
+ }
63
+ inner = httpChatAdapter({ baseURL, apiKey, timeoutMs });
64
+ }
65
+ if (opts.retry === false)
66
+ return inner;
67
+ return retryingAdapter(inner, opts.retry ?? {});
68
+ }
69
+ /**
70
+ * Build the provider used by `klyro run` / `klyro chat` — honors --provider
71
+ * flag, then KLYRO_PROVIDER env, then heuristic.
72
+ *
73
+ * This is the version called from CLI entry points.
74
+ */
75
+ export function buildProviderFromCli(args) {
76
+ const provider = args.provider;
77
+ if (provider && provider !== 'openai' && provider !== 'anthropic') {
78
+ throw new Error(`unknown provider: ${provider}`);
79
+ }
80
+ return buildProvider({
81
+ provider,
82
+ baseURL: args.baseUrl,
83
+ apiKey: args.apiKey,
84
+ timeoutMs: args.timeoutMs,
85
+ });
86
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Retry wrapper for ProviderAdapter.
3
+ *
4
+ * Wraps a ProviderAdapter.stream so that on a yielded `error` event with
5
+ * `retryable: true`, the request is re-issued with exponential backoff +
6
+ * jitter. Non-retryable errors fall through immediately.
7
+ *
8
+ * Important: this only retries errors that the adapter itself surfaces
9
+ * via StreamEvent. The adapter is responsible for translating HTTP
10
+ * status codes to retryable: true/false. We never reach into the
11
+ * adapter to retry raw transport errors.
12
+ *
13
+ * The retry consumes the entire generator each attempt, then re-issues.
14
+ * Between attempts we sleep with backoff:
15
+ *
16
+ * delay = min(maxMs, baseMs * 2^attempt) ± jitter
17
+ *
18
+ * The default policy matches the L6 plan: 3 attempts, 500ms base, 8s cap.
19
+ */
20
+ import type { ProviderAdapter } from './provider-adapter.js';
21
+ export interface RetryOptions {
22
+ maxAttempts: number;
23
+ baseMs: number;
24
+ maxMs: number;
25
+ /** Per-attempt AbortSignal honored — abort cancels the sleep + the next call. */
26
+ signal?: AbortSignal;
27
+ /** Sleep impl — tests inject a synchronous zero. Default: real setTimeout. */
28
+ sleep?: (ms: number) => Promise<void>;
29
+ /** Test hook: called once per attempt with 0-indexed attempt number. */
30
+ onAttempt?: (attempt: number) => void;
31
+ }
32
+ export declare const DEFAULT_RETRY: Required<Omit<RetryOptions, 'signal' | 'onAttempt'>>;
33
+ export declare function computeBackoff(attempt: number, baseMs: number, maxMs: number): number;
34
+ export declare function retryingAdapter(inner: ProviderAdapter, opts?: Partial<RetryOptions>): ProviderAdapter;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Retry wrapper for ProviderAdapter.
3
+ *
4
+ * Wraps a ProviderAdapter.stream so that on a yielded `error` event with
5
+ * `retryable: true`, the request is re-issued with exponential backoff +
6
+ * jitter. Non-retryable errors fall through immediately.
7
+ *
8
+ * Important: this only retries errors that the adapter itself surfaces
9
+ * via StreamEvent. The adapter is responsible for translating HTTP
10
+ * status codes to retryable: true/false. We never reach into the
11
+ * adapter to retry raw transport errors.
12
+ *
13
+ * The retry consumes the entire generator each attempt, then re-issues.
14
+ * Between attempts we sleep with backoff:
15
+ *
16
+ * delay = min(maxMs, baseMs * 2^attempt) ± jitter
17
+ *
18
+ * The default policy matches the L6 plan: 3 attempts, 500ms base, 8s cap.
19
+ */
20
+ export const DEFAULT_RETRY = {
21
+ maxAttempts: 3,
22
+ baseMs: 500,
23
+ maxMs: 8_000,
24
+ sleep: defaultSleep,
25
+ };
26
+ function defaultSleep(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+ async function* streamWithAbort(source, signal) {
30
+ if (!signal) {
31
+ for await (const ev of source)
32
+ yield ev;
33
+ return;
34
+ }
35
+ const it = source[Symbol.asyncIterator]();
36
+ while (true) {
37
+ if (signal.aborted)
38
+ return;
39
+ const next = await it.next();
40
+ if (next.done)
41
+ return;
42
+ yield next.value;
43
+ }
44
+ }
45
+ export function computeBackoff(attempt, baseMs, maxMs) {
46
+ const exp = Math.min(maxMs, baseMs * 2 ** attempt);
47
+ // Jitter: ±25% to spread thundering herds.
48
+ const jitter = exp * 0.25 * (Math.random() * 2 - 1);
49
+ return Math.max(0, Math.floor(exp + jitter));
50
+ }
51
+ export function retryingAdapter(inner, opts = {}) {
52
+ const cfg = { ...DEFAULT_RETRY, ...opts };
53
+ const sleep = opts.sleep ?? defaultSleep;
54
+ return {
55
+ id: `${inner.id}+retry`,
56
+ async *stream(req, signal) {
57
+ const effectiveSignal = signal ?? opts.signal;
58
+ for (let attempt = 0; attempt < cfg.maxAttempts; attempt++) {
59
+ opts.onAttempt?.(attempt);
60
+ if (effectiveSignal?.aborted)
61
+ return;
62
+ let sawRetryable = false;
63
+ let lastError = null;
64
+ for await (const ev of streamWithAbort(inner.stream(req), effectiveSignal)) {
65
+ if (effectiveSignal?.aborted)
66
+ return;
67
+ if (ev.kind === 'error' && ev.retryable) {
68
+ // Buffer the retryable error; don't yield it yet. We'll either
69
+ // re-issue (and the caller will never see the error) or, on
70
+ // final attempt, yield it as the terminal error.
71
+ sawRetryable = true;
72
+ lastError = ev;
73
+ break; // stop consuming; the stream is dead on retryable errors.
74
+ }
75
+ yield ev;
76
+ }
77
+ if (!sawRetryable)
78
+ return; // success or non-retryable error — done.
79
+ if (attempt === cfg.maxAttempts - 1) {
80
+ // Final attempt failed — surface the error.
81
+ if (lastError)
82
+ yield lastError;
83
+ return;
84
+ }
85
+ const delay = computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
86
+ if (delay > 0)
87
+ await sleep(delay);
88
+ }
89
+ },
90
+ };
91
+ }