@yeaft/webchat-agent 0.1.399 → 0.1.408

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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * engine.js — Yeaft query loop
3
+ *
4
+ * The engine is the core orchestrator:
5
+ * 1. Build messages array
6
+ * 2. Call adapter.stream()
7
+ * 3. Collect text + tool_calls from stream events
8
+ * 4. If tool_calls → execute tools → append results → goto 2
9
+ * 5. If end_turn → done
10
+ * 6. If max_tokens → done (Phase 2: auto-continue)
11
+ *
12
+ * Pattern derived from Claude Code's query loop (src/query.ts).
13
+ */
14
+
15
+ import { randomUUID } from 'crypto';
16
+ import { buildSystemPrompt } from './prompts.js';
17
+
18
+ /** Maximum number of turns before the engine stops to prevent infinite loops. */
19
+ const MAX_TURNS = 25;
20
+
21
+ // ─── Engine Events (superset of adapter events) ──────────────────
22
+
23
+ /**
24
+ * @typedef {{ type: 'turn_start', turnNumber: number }} TurnStartEvent
25
+ * @typedef {{ type: 'turn_end', turnNumber: number, stopReason: string }} TurnEndEvent
26
+ * @typedef {{ type: 'tool_start', id: string, name: string, input: object }} ToolStartEvent
27
+ * @typedef {{ type: 'tool_end', id: string, name: string, output: string, isError: boolean }} ToolEndEvent
28
+ *
29
+ * @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent} EngineEvent
30
+ */
31
+
32
+ // ─── Engine ──────────────────────────────────────────────────────
33
+
34
+ export class Engine {
35
+ /** @type {import('./llm/adapter.js').LLMAdapter} */
36
+ #adapter;
37
+
38
+ /** @type {import('./debug-trace.js').DebugTrace | import('./debug-trace.js').NullTrace} */
39
+ #trace;
40
+
41
+ /** @type {object} */
42
+ #config;
43
+
44
+ /** @type {Map<string, { name: string, description: string, parameters: object, execute: function }>} */
45
+ #tools;
46
+
47
+ /** @type {string} */
48
+ #traceId;
49
+
50
+ /**
51
+ * @param {{ adapter: import('./llm/adapter.js').LLMAdapter, trace: object, config: object }} params
52
+ */
53
+ constructor({ adapter, trace, config }) {
54
+ this.#adapter = adapter;
55
+ this.#trace = trace;
56
+ this.#config = config;
57
+ this.#tools = new Map();
58
+ this.#traceId = randomUUID();
59
+ }
60
+
61
+ /**
62
+ * Register a tool that the LLM can call.
63
+ *
64
+ * @param {{ name: string, description: string, parameters: object, execute: (input: object, ctx?: { signal?: AbortSignal }) => Promise<string> }} tool
65
+ */
66
+ registerTool(tool) {
67
+ this.#tools.set(tool.name, tool);
68
+ }
69
+
70
+ /**
71
+ * Unregister a tool.
72
+ *
73
+ * @param {string} name
74
+ */
75
+ unregisterTool(name) {
76
+ this.#tools.delete(name);
77
+ }
78
+
79
+ /**
80
+ * Get the list of registered tool definitions (for passing to the adapter).
81
+ *
82
+ * @returns {import('./llm/adapter.js').UnifiedToolDef[]}
83
+ */
84
+ #getToolDefs() {
85
+ const defs = [];
86
+ for (const [, tool] of this.#tools) {
87
+ defs.push({
88
+ name: tool.name,
89
+ description: tool.description,
90
+ parameters: tool.parameters,
91
+ });
92
+ }
93
+ return defs;
94
+ }
95
+
96
+ /**
97
+ * Build the system prompt.
98
+ *
99
+ * @param {string} mode — 'chat' | 'work' | 'dream'
100
+ * @returns {string}
101
+ */
102
+ #buildSystemPrompt(mode) {
103
+ return buildSystemPrompt({
104
+ language: this.#config.language || 'en',
105
+ mode,
106
+ toolNames: Array.from(this.#tools.keys()),
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Run a query — the main loop.
112
+ *
113
+ * Yields EngineEvent objects that the caller (CLI, web) can consume
114
+ * to render output in real-time.
115
+ *
116
+ * @param {{ prompt: string, mode?: string, messages?: Array, signal?: AbortSignal }} params
117
+ * @yields {EngineEvent}
118
+ */
119
+ async *query({ prompt, mode = 'chat', messages = [], signal }) {
120
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
121
+ yield {
122
+ type: 'error',
123
+ error: new Error('prompt is required and must be a non-empty string'),
124
+ retryable: false,
125
+ };
126
+ return;
127
+ }
128
+
129
+ const systemPrompt = this.#buildSystemPrompt(mode);
130
+
131
+ // Build conversation: existing messages + new user message
132
+ const conversationMessages = [
133
+ ...messages,
134
+ { role: 'user', content: prompt },
135
+ ];
136
+
137
+ const toolDefs = this.#getToolDefs();
138
+ let turnNumber = 0;
139
+
140
+ while (true) {
141
+ turnNumber++;
142
+
143
+ // Safety: prevent infinite loops
144
+ if (turnNumber > MAX_TURNS) {
145
+ yield {
146
+ type: 'error',
147
+ error: new Error(`Max turns (${MAX_TURNS}) reached — stopping to prevent infinite loop`),
148
+ retryable: false,
149
+ };
150
+ break;
151
+ }
152
+
153
+ const turnId = this.#trace.startTurn({
154
+ traceId: this.#traceId,
155
+ mode,
156
+ turnNumber,
157
+ });
158
+
159
+ const startTime = Date.now();
160
+ let responseText = '';
161
+ const toolCalls = [];
162
+ let stopReason = 'end_turn';
163
+ const totalUsage = { inputTokens: 0, outputTokens: 0 };
164
+
165
+ yield { type: 'turn_start', turnNumber };
166
+
167
+ try {
168
+ // Stream from adapter
169
+ // Note: pass a snapshot of messages so later mutations don't affect the adapter
170
+ for await (const event of this.#adapter.stream({
171
+ model: this.#config.model,
172
+ system: systemPrompt,
173
+ messages: [...conversationMessages],
174
+ tools: toolDefs.length > 0 ? toolDefs : undefined,
175
+ maxTokens: this.#config.maxOutputTokens || 16384,
176
+ signal,
177
+ })) {
178
+ switch (event.type) {
179
+ case 'text_delta':
180
+ responseText += event.text;
181
+ yield event;
182
+ break;
183
+ case 'thinking_delta':
184
+ yield event;
185
+ break;
186
+ case 'tool_call':
187
+ toolCalls.push(event);
188
+ yield event;
189
+ break;
190
+ case 'usage':
191
+ totalUsage.inputTokens += event.inputTokens;
192
+ totalUsage.outputTokens += event.outputTokens;
193
+ yield event;
194
+ break;
195
+ case 'stop':
196
+ stopReason = event.stopReason;
197
+ yield event;
198
+ break;
199
+ case 'error':
200
+ yield event;
201
+ break;
202
+ }
203
+ }
204
+ } catch (err) {
205
+ // Adapter threw an exception (network, auth, etc.)
206
+ const latencyMs = Date.now() - startTime;
207
+ this.#trace.endTurn(turnId, {
208
+ model: this.#config.model,
209
+ inputTokens: totalUsage.inputTokens,
210
+ outputTokens: totalUsage.outputTokens,
211
+ stopReason: 'error',
212
+ latencyMs,
213
+ responseText,
214
+ });
215
+
216
+ yield {
217
+ type: 'error',
218
+ error: err,
219
+ retryable: err.name === 'LLMRateLimitError' || err.name === 'LLMServerError',
220
+ };
221
+ yield { type: 'turn_end', turnNumber, stopReason: 'error' };
222
+ break;
223
+ }
224
+
225
+ const latencyMs = Date.now() - startTime;
226
+
227
+ // Record turn in debug trace
228
+ this.#trace.endTurn(turnId, {
229
+ model: this.#config.model,
230
+ inputTokens: totalUsage.inputTokens,
231
+ outputTokens: totalUsage.outputTokens,
232
+ stopReason,
233
+ latencyMs,
234
+ responseText,
235
+ });
236
+
237
+ // Append assistant message to conversation
238
+ const assistantMsg = { role: 'assistant', content: responseText };
239
+ if (toolCalls.length > 0) {
240
+ assistantMsg.toolCalls = toolCalls.map(tc => ({
241
+ id: tc.id,
242
+ name: tc.name,
243
+ input: tc.input,
244
+ }));
245
+ }
246
+ conversationMessages.push(assistantMsg);
247
+
248
+ // If no tool calls, we're done
249
+ if (stopReason !== 'tool_use' || toolCalls.length === 0) {
250
+ yield { type: 'turn_end', turnNumber, stopReason };
251
+ break;
252
+ }
253
+
254
+ // Execute tool calls and feed results back
255
+ for (const tc of toolCalls) {
256
+ const tool = this.#tools.get(tc.name);
257
+ const toolStartTime = Date.now();
258
+
259
+ let output;
260
+ let isError = false;
261
+
262
+ if (!tool) {
263
+ output = `Error: unknown tool "${tc.name}"`;
264
+ isError = true;
265
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
266
+ } else {
267
+ try {
268
+ yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input };
269
+ output = await tool.execute(tc.input, { signal });
270
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false };
271
+ } catch (err) {
272
+ output = `Error: ${err.message}`;
273
+ isError = true;
274
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
275
+ }
276
+ }
277
+
278
+ const toolDurationMs = Date.now() - toolStartTime;
279
+
280
+ // Log tool to debug trace
281
+ this.#trace.logTool(turnId, {
282
+ toolName: tc.name,
283
+ toolInput: JSON.stringify(tc.input),
284
+ toolOutput: output,
285
+ durationMs: toolDurationMs,
286
+ isError,
287
+ });
288
+
289
+ // Append tool result to conversation
290
+ conversationMessages.push({
291
+ role: 'tool',
292
+ toolCallId: tc.id,
293
+ content: output,
294
+ isError,
295
+ });
296
+ }
297
+
298
+ yield { type: 'turn_end', turnNumber, stopReason: 'tool_use' };
299
+
300
+ // Loop back to call adapter again with tool results
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Get the trace ID for this engine instance.
306
+ * @returns {string}
307
+ */
308
+ get traceId() {
309
+ return this.#traceId;
310
+ }
311
+
312
+ /**
313
+ * Get registered tool names.
314
+ * @returns {string[]}
315
+ */
316
+ get toolNames() {
317
+ return Array.from(this.#tools.keys());
318
+ }
319
+ }
package/unify/index.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * index.js — Yeaft Unify module entry point
3
+ *
4
+ * Re-exports all public APIs for external consumption.
5
+ */
6
+
7
+ export { initYeaftDir, DEFAULT_YEAFT_DIR } from './init.js';
8
+ export { loadConfig, parseFrontmatter } from './config.js';
9
+ export { DebugTrace, NullTrace, createTrace } from './debug-trace.js';
10
+ export {
11
+ LLMAdapter,
12
+ LLMRateLimitError,
13
+ LLMAuthError,
14
+ LLMContextError,
15
+ LLMServerError,
16
+ LLMAbortError,
17
+ createLLMAdapter,
18
+ } from './llm/adapter.js';
19
+ export { MODEL_REGISTRY, resolveModel, listModels, isKnownModel } from './models.js';
20
+ export { buildSystemPrompt, SUPPORTED_LANGUAGES } from './prompts.js';
21
+ export { Engine } from './engine.js';
package/unify/init.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * init.js — Yeaft directory structure initialization
3
+ *
4
+ * Ensures ~/.yeaft/ and all required subdirectories exist.
5
+ * Creates default config.md, MEMORY.md, and conversation/index.md if missing.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
11
+
12
+ /** Default directory for Yeaft data. */
13
+ export const DEFAULT_YEAFT_DIR = join(homedir(), '.yeaft');
14
+
15
+ /** Subdirectories that must exist inside the Yeaft data directory. */
16
+ const SUBDIRS = [
17
+ 'conversation/messages',
18
+ 'conversation/cold',
19
+ 'conversation/blobs',
20
+ 'memory/entries',
21
+ 'tasks',
22
+ 'dream',
23
+ 'skills',
24
+ ];
25
+
26
+ /** Default config.md content (YAML frontmatter + markdown body). */
27
+ const DEFAULT_CONFIG = `---
28
+ model: claude-sonnet-4-20250514
29
+ language: en
30
+ debug: false
31
+ maxContextTokens: 200000
32
+ ---
33
+
34
+ # Yeaft Config
35
+
36
+ Edit the YAML frontmatter above to change settings.
37
+ The \`model\` field is a model ID (e.g. \`gpt-5\`, \`claude-sonnet-4-20250514\`).
38
+ Yeaft auto-detects the correct API adapter and endpoint from the model ID.
39
+
40
+ ## Language
41
+
42
+ Set \`language\` in frontmatter or \`YEAFT_LANGUAGE\` env var:
43
+ - \`en\` — English system prompts (default)
44
+ - \`zh\` — Chinese system prompts (中文系统提示)
45
+
46
+ ## Model IDs
47
+
48
+ - \`claude-sonnet-4-20250514\` (default)
49
+ - \`claude-opus-4-20250514\`
50
+ - \`gpt-5\`, \`gpt-5.4\`, \`gpt-4.1\`, \`gpt-4.1-mini\`
51
+ - \`o3\`, \`o4-mini\`
52
+ - \`deepseek-chat\`, \`deepseek-reasoner\`
53
+ - \`gemini-2.5-pro\`, \`gemini-2.5-flash\`
54
+
55
+ ## API Keys
56
+
57
+ Store API keys in \`~/.yeaft/.env\` (recommended) or export as env vars:
58
+
59
+ \`\`\`bash
60
+ # ~/.yeaft/.env
61
+ YEAFT_API_KEY=sk-ant-... # Anthropic
62
+ YEAFT_OPENAI_API_KEY=sk-... # OpenAI / DeepSeek / Gemini
63
+ \`\`\`
64
+
65
+ ## Environment Variables
66
+
67
+ Shell env vars take precedence over .env and config.md:
68
+
69
+ - \`YEAFT_MODEL\` — override model ID
70
+ - \`YEAFT_LANGUAGE\` — language for system prompts (en/zh)
71
+ - \`YEAFT_API_KEY\` — Anthropic API key
72
+ - \`YEAFT_OPENAI_API_KEY\` — OpenAI-compatible API key
73
+ - \`YEAFT_PROXY_URL\` — CopilotProxy URL (default: http://localhost:6628)
74
+ - \`YEAFT_DEBUG\` — enable debug mode (1/true)
75
+ - \`YEAFT_DIR\` — data directory (default: ~/.yeaft)
76
+ `;
77
+
78
+ /** Default MEMORY.md content. */
79
+ const DEFAULT_MEMORY = `# Yeaft Memory
80
+
81
+ This file stores persistent memory entries. The agent will read and update this file.
82
+
83
+ ## Facts
84
+
85
+ ## Preferences
86
+
87
+ ## Project Context
88
+
89
+ `;
90
+
91
+ /** Default conversation/index.md content. */
92
+ const DEFAULT_CONVERSATION_INDEX = `---
93
+ lastMessageId: null
94
+ totalMessages: 0
95
+ ---
96
+
97
+ # Conversation Index
98
+
99
+ This file tracks the conversation state for the "one eternal conversation" model.
100
+ `;
101
+
102
+ /**
103
+ * Initialize the Yeaft data directory structure.
104
+ *
105
+ * @param {string} [dir] — Root directory path. Defaults to ~/.yeaft/
106
+ * @returns {{ dir: string, created: string[] }} — The root dir and list of created paths
107
+ */
108
+ export function initYeaftDir(dir) {
109
+ const root = dir || DEFAULT_YEAFT_DIR;
110
+ const created = [];
111
+
112
+ // Ensure root exists
113
+ if (!existsSync(root)) {
114
+ mkdirSync(root, { recursive: true });
115
+ created.push(root);
116
+ }
117
+
118
+ // Ensure all subdirectories exist
119
+ for (const sub of SUBDIRS) {
120
+ const fullPath = join(root, sub);
121
+ if (!existsSync(fullPath)) {
122
+ mkdirSync(fullPath, { recursive: true });
123
+ created.push(fullPath);
124
+ }
125
+ }
126
+
127
+ // Create default files if they don't exist
128
+ const configPath = join(root, 'config.md');
129
+ if (!existsSync(configPath)) {
130
+ writeFileSync(configPath, DEFAULT_CONFIG, 'utf8');
131
+ created.push(configPath);
132
+ }
133
+
134
+ const memoryPath = join(root, 'memory', 'MEMORY.md');
135
+ if (!existsSync(memoryPath)) {
136
+ writeFileSync(memoryPath, DEFAULT_MEMORY, 'utf8');
137
+ created.push(memoryPath);
138
+ }
139
+
140
+ const indexPath = join(root, 'conversation', 'index.md');
141
+ if (!existsSync(indexPath)) {
142
+ writeFileSync(indexPath, DEFAULT_CONVERSATION_INDEX, 'utf8');
143
+ created.push(indexPath);
144
+ }
145
+
146
+ return { dir: root, created };
147
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * adapter.js — LLM Adapter base class, unified types, and factory
3
+ *
4
+ * Design decision (2026-04-10): Only two adapters needed:
5
+ * 1. AnthropicAdapter — Anthropic Messages API
6
+ * 2. ChatCompletionsAdapter — OpenAI Chat Completions API (covers GPT, DeepSeek, CopilotProxy, etc.)
7
+ *
8
+ * The engine sees only unified types — it never knows which API is underneath.
9
+ */
10
+
11
+ // ─── Unified Types ─────────────────────────────────────────────
12
+
13
+ /**
14
+ * @typedef {Object} UnifiedToolDef
15
+ * @property {string} name
16
+ * @property {string} description
17
+ * @property {object} parameters — JSON Schema
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} UnifiedToolCall
22
+ * @property {string} id
23
+ * @property {string} name
24
+ * @property {object} input — Parsed object (not JSON string)
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} UnifiedToolResult
29
+ * @property {string} toolCallId
30
+ * @property {string} output
31
+ * @property {boolean} [isError]
32
+ */
33
+
34
+ // ─── Unified Event Stream ──────────────────────────────────────
35
+
36
+ /**
37
+ * @typedef {{ type: 'text_delta', text: string }} TextDeltaEvent
38
+ * @typedef {{ type: 'thinking_delta', text: string }} ThinkingDeltaEvent
39
+ * @typedef {{ type: 'tool_call', id: string, name: string, input: object }} ToolCallEvent
40
+ * @typedef {{ type: 'usage', inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWriteTokens?: number }} UsageEvent
41
+ * @typedef {{ type: 'stop', stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }} StopEvent
42
+ * @typedef {{ type: 'error', error: Error, retryable: boolean }} ErrorEvent
43
+ *
44
+ * @typedef {TextDeltaEvent | ThinkingDeltaEvent | ToolCallEvent | UsageEvent | StopEvent | ErrorEvent} StreamEvent
45
+ */
46
+
47
+ // ─── Unified Message Types ─────────────────────────────────────
48
+
49
+ /**
50
+ * @typedef {{ role: 'system', content: string }} SystemMessage
51
+ * @typedef {{ role: 'user', content: string }} UserMessage
52
+ * @typedef {{ role: 'assistant', content: string, toolCalls?: UnifiedToolCall[] }} AssistantMessage
53
+ * @typedef {{ role: 'tool', toolCallId: string, content: string, isError?: boolean }} ToolMessage
54
+ *
55
+ * @typedef {SystemMessage | UserMessage | AssistantMessage | ToolMessage} UnifiedMessage
56
+ */
57
+
58
+ // ─── Error Types ───────────────────────────────────────────────
59
+
60
+ /** Rate limit error (429, 529) — retryable with backoff. */
61
+ export class LLMRateLimitError extends Error {
62
+ constructor(message, statusCode, retryAfterMs = null) {
63
+ super(message);
64
+ this.name = 'LLMRateLimitError';
65
+ this.statusCode = statusCode;
66
+ this.retryAfterMs = retryAfterMs;
67
+ }
68
+ }
69
+
70
+ /** Authentication error (401, 403) — need to re-authenticate. */
71
+ export class LLMAuthError extends Error {
72
+ constructor(message, statusCode) {
73
+ super(message);
74
+ this.name = 'LLMAuthError';
75
+ this.statusCode = statusCode;
76
+ }
77
+ }
78
+
79
+ /** Context too long error (413 or API-specific) — need compaction. */
80
+ export class LLMContextError extends Error {
81
+ constructor(message) {
82
+ super(message);
83
+ this.name = 'LLMContextError';
84
+ }
85
+ }
86
+
87
+ /** Server error (500, 502, 503) — retryable. */
88
+ export class LLMServerError extends Error {
89
+ constructor(message, statusCode) {
90
+ super(message);
91
+ this.name = 'LLMServerError';
92
+ this.statusCode = statusCode;
93
+ }
94
+ }
95
+
96
+ /** Abort error — signal was aborted. */
97
+ export class LLMAbortError extends Error {
98
+ constructor() {
99
+ super('Request aborted');
100
+ this.name = 'LLMAbortError';
101
+ }
102
+ }
103
+
104
+ // ─── Base Class ────────────────────────────────────────────────
105
+
106
+ /**
107
+ * LLMAdapter — Abstract base class for LLM API adapters.
108
+ *
109
+ * Subclasses implement stream() and call() to talk to a specific API,
110
+ * translating between the unified types and the wire format.
111
+ */
112
+ export class LLMAdapter {
113
+ /**
114
+ * @param {object} config — Adapter-specific configuration
115
+ */
116
+ constructor(config = {}) {
117
+ this.config = config;
118
+ }
119
+
120
+ /**
121
+ * Stream a model response with tool support (the query loop call).
122
+ *
123
+ * @param {{ model: string, system: string, messages: UnifiedMessage[], tools?: UnifiedToolDef[], maxTokens?: number, signal?: AbortSignal }} params
124
+ * @returns {AsyncGenerator<StreamEvent>}
125
+ */
126
+ async *stream(params) { // eslint-disable-line no-unused-vars
127
+ throw new Error('stream() must be implemented by subclass');
128
+ }
129
+
130
+ /**
131
+ * Make a single model call without tools (for side queries like summarization).
132
+ *
133
+ * @param {{ model: string, system: string, messages: UnifiedMessage[], maxTokens?: number, signal?: AbortSignal }} params
134
+ * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number } }>}
135
+ */
136
+ async call(params) { // eslint-disable-line no-unused-vars
137
+ throw new Error('call() must be implemented by subclass');
138
+ }
139
+ }
140
+
141
+ // ─── Factory ───────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Create an LLM adapter based on configuration.
145
+ *
146
+ * @param {object} config — From loadConfig()
147
+ * @returns {Promise<LLMAdapter>}
148
+ */
149
+ export async function createLLMAdapter(config) {
150
+ // Normalize adapter name — accept 'chat-completions' as alias for 'openai'
151
+ const adapter = config.adapter === 'chat-completions' ? 'openai' : config.adapter;
152
+
153
+ if (adapter === 'anthropic' || (!adapter && config.apiKey)) {
154
+ if (!config.apiKey) {
155
+ throw new Error('Anthropic adapter requires YEAFT_API_KEY');
156
+ }
157
+ const { AnthropicAdapter } = await import('./anthropic.js');
158
+ return new AnthropicAdapter({
159
+ apiKey: config.apiKey,
160
+ baseUrl: config.baseUrl || undefined, // AnthropicAdapter has its own default
161
+ });
162
+ }
163
+
164
+ if (adapter === 'openai' || (!adapter && config.openaiApiKey)) {
165
+ if (!config.openaiApiKey && !config.apiKey) {
166
+ throw new Error('OpenAI adapter requires YEAFT_OPENAI_API_KEY (or YEAFT_API_KEY as fallback)');
167
+ }
168
+ const { ChatCompletionsAdapter } = await import('./chat-completions.js');
169
+ return new ChatCompletionsAdapter({
170
+ apiKey: config.openaiApiKey || config.apiKey,
171
+ baseUrl: config.baseUrl || 'https://api.openai.com/v1',
172
+ });
173
+ }
174
+
175
+ if (adapter === 'proxy' || (!adapter && config.proxyUrl)) {
176
+ const { ChatCompletionsAdapter } = await import('./chat-completions.js');
177
+ return new ChatCompletionsAdapter({
178
+ apiKey: 'proxy', // CopilotProxy handles auth
179
+ baseUrl: `${config.proxyUrl}/v1`,
180
+ });
181
+ }
182
+
183
+ throw new Error(
184
+ 'No LLM adapter configured. Set YEAFT_API_KEY (Anthropic), YEAFT_OPENAI_API_KEY (OpenAI), or YEAFT_PROXY_URL (CopilotProxy).',
185
+ );
186
+ }