@hevmind/ask 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -13
- package/bin/ask-launcher.mjs +19 -2
- package/openapi.yaml +53 -7
- package/package.json +6 -6
- package/skills/build-digest/SKILL.md +7 -7
- package/src/digest/build.ts +54 -16
- package/src/digest/cli.ts +19 -7
- package/src/digest/frontmatter.ts +7 -0
- package/src/digest/schema.ts +3 -0
- package/src/digest/tree.ts +259 -0
- package/src/digest/verify.ts +2 -11
- package/src/endpoint.ts +121 -5
- package/src/index.ts +1 -1
- package/src/integration.ts +16 -14
- package/src/llm-openai.ts +330 -0
- package/src/observability.ts +3 -1
- package/src/providers.ts +81 -0
- package/src/types.ts +34 -6
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// OpenAI-compatible Chat Completions client over fetch. One translation layer
|
|
2
|
+
// covers OpenAI, OpenRouter, and any Chat Completions-compatible endpoint: the
|
|
3
|
+
// rest of the package keeps speaking the internal (Anthropic-shaped) block
|
|
4
|
+
// types, and this module converts both ways. Like `llm.ts`, it stays free of
|
|
5
|
+
// runtime dependencies and edge-runtime friendly.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AnthropicResponse,
|
|
9
|
+
AnthropicTextBlock,
|
|
10
|
+
AnthropicUsage,
|
|
11
|
+
CallClaudeOptions,
|
|
12
|
+
StreamEvent,
|
|
13
|
+
} from './llm.ts';
|
|
14
|
+
|
|
15
|
+
export interface OpenAiEndpoint {
|
|
16
|
+
/** API base, e.g. `https://api.openai.com/v1` or `https://openrouter.ai/api/v1`. */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/**
|
|
19
|
+
* OpenAI's reasoning models reject `max_tokens` and want
|
|
20
|
+
* `max_completion_tokens`; OpenRouter normalizes `max_tokens` for every
|
|
21
|
+
* underlying provider.
|
|
22
|
+
*/
|
|
23
|
+
tokenParam: 'max_tokens' | 'max_completion_tokens';
|
|
24
|
+
/** Human label used in error messages, e.g. `OpenAI` or `OpenRouter`. */
|
|
25
|
+
label: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface OpenAiToolCall {
|
|
29
|
+
id: string;
|
|
30
|
+
type: 'function';
|
|
31
|
+
function: { name: string; arguments: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface OpenAiMessage {
|
|
35
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
36
|
+
content: string | null;
|
|
37
|
+
tool_calls?: OpenAiToolCall[];
|
|
38
|
+
tool_call_id?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function systemText(system: string | AnthropicTextBlock[]): string {
|
|
42
|
+
// cache_control is Anthropic-specific; OpenAI-compatible APIs cache on their own.
|
|
43
|
+
return typeof system === 'string' ? system : system.map((block) => block.text).join('\n\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Converts the internal (Anthropic-shaped) conversation into Chat Completions messages. */
|
|
47
|
+
export function toOpenAiMessages(opts: Pick<CallClaudeOptions, 'system' | 'messages'>): OpenAiMessage[] {
|
|
48
|
+
const out: OpenAiMessage[] = [{ role: 'system', content: systemText(opts.system) }];
|
|
49
|
+
|
|
50
|
+
for (const message of opts.messages) {
|
|
51
|
+
if (typeof message.content === 'string') {
|
|
52
|
+
out.push({ role: message.role, content: message.content });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(message.content)) continue;
|
|
56
|
+
const blocks = message.content as Array<Record<string, unknown>>;
|
|
57
|
+
|
|
58
|
+
if (message.role === 'assistant') {
|
|
59
|
+
const text = blocks
|
|
60
|
+
.filter((block) => block.type === 'text')
|
|
61
|
+
.map((block) => String(block.text ?? ''))
|
|
62
|
+
.join('');
|
|
63
|
+
const toolCalls: OpenAiToolCall[] = blocks
|
|
64
|
+
.filter((block) => block.type === 'tool_use')
|
|
65
|
+
.map((block) => ({
|
|
66
|
+
id: String(block.id ?? ''),
|
|
67
|
+
type: 'function',
|
|
68
|
+
function: { name: String(block.name ?? ''), arguments: JSON.stringify(block.input ?? {}) },
|
|
69
|
+
}));
|
|
70
|
+
out.push({
|
|
71
|
+
role: 'assistant',
|
|
72
|
+
content: text || null,
|
|
73
|
+
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// User turns: tool_result blocks must become role:"tool" messages directly
|
|
79
|
+
// after the assistant turn that issued the calls; any text follows as a
|
|
80
|
+
// plain user message.
|
|
81
|
+
for (const block of blocks) {
|
|
82
|
+
if (block.type !== 'tool_result') continue;
|
|
83
|
+
out.push({
|
|
84
|
+
role: 'tool',
|
|
85
|
+
tool_call_id: String(block.tool_use_id ?? ''),
|
|
86
|
+
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? ''),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const text = blocks
|
|
90
|
+
.filter((block) => block.type === 'text')
|
|
91
|
+
.map((block) => String(block.text ?? ''))
|
|
92
|
+
.join('');
|
|
93
|
+
if (text) out.push({ role: 'user', content: text });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Builds the full Chat Completions request body from internal call options. */
|
|
100
|
+
export function toOpenAiRequest(
|
|
101
|
+
opts: CallClaudeOptions,
|
|
102
|
+
endpoint: OpenAiEndpoint,
|
|
103
|
+
stream: boolean,
|
|
104
|
+
): Record<string, unknown> {
|
|
105
|
+
return {
|
|
106
|
+
model: opts.model,
|
|
107
|
+
[endpoint.tokenParam]: opts.maxTokens ?? 2048,
|
|
108
|
+
messages: toOpenAiMessages(opts),
|
|
109
|
+
...(opts.tools?.length
|
|
110
|
+
? {
|
|
111
|
+
tools: opts.tools.map((tool) => ({
|
|
112
|
+
type: 'function',
|
|
113
|
+
function: { name: tool.name, description: tool.description, parameters: tool.input_schema },
|
|
114
|
+
})),
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
...(opts.toolChoice
|
|
118
|
+
? {
|
|
119
|
+
tool_choice:
|
|
120
|
+
opts.toolChoice.type === 'tool'
|
|
121
|
+
? { type: 'function', function: { name: opts.toolChoice.name } }
|
|
122
|
+
: 'auto',
|
|
123
|
+
}
|
|
124
|
+
: {}),
|
|
125
|
+
...(stream ? { stream: true, stream_options: { include_usage: true } } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mapStopReason(finishReason: string | null | undefined): string | null {
|
|
130
|
+
if (finishReason === 'tool_calls') return 'tool_use';
|
|
131
|
+
if (finishReason === 'stop') return 'end_turn';
|
|
132
|
+
if (finishReason === 'length') return 'max_tokens';
|
|
133
|
+
return finishReason ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseToolInput(args: string): unknown {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(args || '{}');
|
|
139
|
+
} catch {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mapUsage(usage: unknown): AnthropicUsage | undefined {
|
|
145
|
+
const u = usage as { prompt_tokens?: number; completion_tokens?: number } | null | undefined;
|
|
146
|
+
if (typeof u?.prompt_tokens !== 'number' && typeof u?.completion_tokens !== 'number') return undefined;
|
|
147
|
+
return { input_tokens: u?.prompt_tokens ?? 0, output_tokens: u?.completion_tokens ?? 0 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requestInit(opts: CallClaudeOptions, endpoint: OpenAiEndpoint, stream: boolean): RequestInit {
|
|
151
|
+
return {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
'content-type': 'application/json',
|
|
155
|
+
authorization: `Bearer ${opts.apiKey}`,
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify(toOpenAiRequest(opts, endpoint, stream)),
|
|
158
|
+
signal: opts.signal,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function completionsUrl(endpoint: OpenAiEndpoint): string {
|
|
163
|
+
return `${endpoint.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function callOpenAi(opts: CallClaudeOptions, endpoint: OpenAiEndpoint): Promise<AnthropicResponse> {
|
|
167
|
+
const res = await fetch(completionsUrl(endpoint), requestInit(opts, endpoint, false));
|
|
168
|
+
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const detail = await res.text().catch(() => '');
|
|
171
|
+
throw new Error(`${endpoint.label} API ${res.status}: ${detail.slice(0, 500)}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const payload = (await res.json()) as {
|
|
175
|
+
choices?: Array<{ message?: { content?: string | null; tool_calls?: OpenAiToolCall[] }; finish_reason?: string | null }>;
|
|
176
|
+
usage?: unknown;
|
|
177
|
+
};
|
|
178
|
+
const choice = payload.choices?.[0];
|
|
179
|
+
const content: AnthropicResponse['content'] = [];
|
|
180
|
+
if (choice?.message?.content) content.push({ type: 'text', text: choice.message.content });
|
|
181
|
+
for (const call of choice?.message?.tool_calls ?? []) {
|
|
182
|
+
content.push({ type: 'tool_use', id: call.id, name: call.function.name, input: parseToolInput(call.function.arguments) });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
content,
|
|
187
|
+
stop_reason: mapStopReason(choice?.finish_reason),
|
|
188
|
+
...(mapUsage(payload.usage) ? { usage: mapUsage(payload.usage) } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Streams a Chat Completions response, yielding text deltas as they arrive and
|
|
194
|
+
* fully-reconstructed tool_use blocks (plus one `stop` event) at the end.
|
|
195
|
+
*/
|
|
196
|
+
export async function* streamOpenAi(opts: CallClaudeOptions, endpoint: OpenAiEndpoint): AsyncGenerator<StreamEvent> {
|
|
197
|
+
const res = await fetch(completionsUrl(endpoint), requestInit(opts, endpoint, true));
|
|
198
|
+
|
|
199
|
+
if (!res.ok || !res.body) {
|
|
200
|
+
const detail = res.ok ? 'no response body' : await res.text().catch(() => '');
|
|
201
|
+
throw new Error(`${endpoint.label} API ${res.status}: ${detail.slice(0, 500)}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const reader = res.body.getReader();
|
|
205
|
+
const decoder = new TextDecoder('utf-8');
|
|
206
|
+
let state = newOpenAiSseState();
|
|
207
|
+
|
|
208
|
+
while (true) {
|
|
209
|
+
const { done, value } = await reader.read();
|
|
210
|
+
if (done) break;
|
|
211
|
+
const out = parseOpenAiSseChunk(decoder.decode(value, { stream: true }), state);
|
|
212
|
+
state = out.state;
|
|
213
|
+
for (const event of out.events) yield event;
|
|
214
|
+
}
|
|
215
|
+
// Streams normally end with `data: [DONE]`; flush here in case one doesn't.
|
|
216
|
+
for (const event of flushOpenAiSse(state)) yield event;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface SseToolCall {
|
|
220
|
+
id: string;
|
|
221
|
+
name: string;
|
|
222
|
+
args: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface OpenAiSseState {
|
|
226
|
+
/** Bytes not yet terminated by a blank line. */
|
|
227
|
+
buffer: string;
|
|
228
|
+
/** Tool calls accumulated by their stream index. */
|
|
229
|
+
toolCalls: Record<number, SseToolCall>;
|
|
230
|
+
usage: AnthropicUsage;
|
|
231
|
+
finishReason: string | null;
|
|
232
|
+
/** Tool-use and stop events were already emitted (on `[DONE]`). */
|
|
233
|
+
flushed: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function newOpenAiSseState(): OpenAiSseState {
|
|
237
|
+
return {
|
|
238
|
+
buffer: '',
|
|
239
|
+
toolCalls: {},
|
|
240
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
241
|
+
finishReason: null,
|
|
242
|
+
flushed: false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Pure, network-free Chat Completions SSE parser. Text deltas surface
|
|
248
|
+
* immediately; tool calls and usage accumulate until `[DONE]` flushes them.
|
|
249
|
+
*/
|
|
250
|
+
export function parseOpenAiSseChunk(
|
|
251
|
+
chunk: string,
|
|
252
|
+
prev: OpenAiSseState,
|
|
253
|
+
): { events: StreamEvent[]; state: OpenAiSseState } {
|
|
254
|
+
const events: StreamEvent[] = [];
|
|
255
|
+
const state = { ...prev, toolCalls: prev.toolCalls, usage: prev.usage };
|
|
256
|
+
state.buffer = prev.buffer + chunk;
|
|
257
|
+
|
|
258
|
+
let sep: number;
|
|
259
|
+
while ((sep = state.buffer.indexOf('\n\n')) !== -1) {
|
|
260
|
+
const frame = state.buffer.slice(0, sep);
|
|
261
|
+
state.buffer = state.buffer.slice(sep + 2);
|
|
262
|
+
|
|
263
|
+
// Non-`data:` lines (OpenRouter emits `: PROCESSING` comments) are dropped.
|
|
264
|
+
const data = frame
|
|
265
|
+
.split('\n')
|
|
266
|
+
.filter((line) => line.startsWith('data:'))
|
|
267
|
+
.map((line) => line.slice(5).trim())
|
|
268
|
+
.join('');
|
|
269
|
+
if (!data) continue;
|
|
270
|
+
if (data === '[DONE]') {
|
|
271
|
+
events.push(...flushOpenAiSse(state));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let payload: Record<string, unknown>;
|
|
276
|
+
try {
|
|
277
|
+
payload = JSON.parse(data) as Record<string, unknown>;
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const mappedUsage = mapUsage(payload.usage);
|
|
283
|
+
if (mappedUsage) state.usage = mappedUsage;
|
|
284
|
+
|
|
285
|
+
const choice = (payload.choices as Array<Record<string, unknown>> | undefined)?.[0];
|
|
286
|
+
if (!choice) continue;
|
|
287
|
+
if (typeof choice.finish_reason === 'string') state.finishReason = choice.finish_reason;
|
|
288
|
+
|
|
289
|
+
const delta = choice.delta as
|
|
290
|
+
| { content?: string | null; tool_calls?: Array<{ index?: number; id?: string; function?: { name?: string; arguments?: string } }> }
|
|
291
|
+
| undefined;
|
|
292
|
+
if (typeof delta?.content === 'string' && delta.content) {
|
|
293
|
+
events.push({ type: 'text', text: delta.content });
|
|
294
|
+
}
|
|
295
|
+
for (const call of delta?.tool_calls ?? []) {
|
|
296
|
+
const index = call.index ?? 0;
|
|
297
|
+
const existing = state.toolCalls[index] ?? { id: '', name: '', args: '' };
|
|
298
|
+
state.toolCalls[index] = {
|
|
299
|
+
id: call.id ?? existing.id,
|
|
300
|
+
name: existing.name + (call.function?.name ?? ''),
|
|
301
|
+
args: existing.args + (call.function?.arguments ?? ''),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { events, state };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Emits accumulated tool_use blocks and the final stop event, exactly once. */
|
|
310
|
+
export function flushOpenAiSse(state: OpenAiSseState): StreamEvent[] {
|
|
311
|
+
if (state.flushed) return [];
|
|
312
|
+
state.flushed = true;
|
|
313
|
+
|
|
314
|
+
const events: StreamEvent[] = [];
|
|
315
|
+
const indexes = Object.keys(state.toolCalls)
|
|
316
|
+
.map(Number)
|
|
317
|
+
.sort((a, b) => a - b);
|
|
318
|
+
for (const index of indexes) {
|
|
319
|
+
const call = state.toolCalls[index];
|
|
320
|
+
events.push({ type: 'tool_use', id: call.id, name: call.name, input: parseToolInput(call.args) });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const hasUsage = state.usage.input_tokens > 0 || state.usage.output_tokens > 0;
|
|
324
|
+
events.push({
|
|
325
|
+
type: 'stop',
|
|
326
|
+
stopReason: mapStopReason(state.finishReason),
|
|
327
|
+
...(hasUsage ? { usage: { ...state.usage } } : {}),
|
|
328
|
+
});
|
|
329
|
+
return events;
|
|
330
|
+
}
|
package/src/observability.ts
CHANGED
|
@@ -66,6 +66,8 @@ export interface TelemetryOptions {
|
|
|
66
66
|
distinctId?: string;
|
|
67
67
|
/** Optional label attached to every event as `agent_scope`. */
|
|
68
68
|
scope?: string;
|
|
69
|
+
/** Inference provider reported as `$ai_provider`; defaults to `anthropic`. */
|
|
70
|
+
provider?: string;
|
|
69
71
|
/** Reuse an existing trace id; one is generated otherwise. */
|
|
70
72
|
traceId?: string;
|
|
71
73
|
/** Cloudflare-style keep-alive so in-flight captures survive response end. */
|
|
@@ -113,7 +115,7 @@ export function makeTelemetry(options: TelemetryOptions = {}): Telemetry {
|
|
|
113
115
|
distinct_id: distinctId,
|
|
114
116
|
properties: {
|
|
115
117
|
$ai_trace_id: traceId,
|
|
116
|
-
$ai_provider: 'anthropic',
|
|
118
|
+
$ai_provider: options.provider ?? 'anthropic',
|
|
117
119
|
$process_person_profile: false, // anonymous — no person profile
|
|
118
120
|
...(scope ? { agent_scope: scope } : {}),
|
|
119
121
|
...properties,
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Inference provider registry. Anthropic keeps its native Messages client;
|
|
2
|
+
// OpenAI and OpenRouter share the Chat Completions client in `llm-openai.ts`,
|
|
3
|
+
// differing only in base URL, key env var, token param, and default models.
|
|
4
|
+
|
|
5
|
+
import { callClaude, streamClaude } from './llm.ts';
|
|
6
|
+
import { callOpenAi, streamOpenAi, type OpenAiEndpoint } from './llm-openai.ts';
|
|
7
|
+
import type { ProviderName } from './types.ts';
|
|
8
|
+
|
|
9
|
+
export type { ProviderName };
|
|
10
|
+
|
|
11
|
+
export interface ProviderInfo {
|
|
12
|
+
name: ProviderName;
|
|
13
|
+
/** Human label for log and error messages. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Environment variable the API key is read from. */
|
|
16
|
+
envKey: string;
|
|
17
|
+
/** Default API base URL (OpenAI-compatible providers only). */
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
/** Default model for the agentic search loop. */
|
|
20
|
+
defaultModel: string;
|
|
21
|
+
/** Default model for the offline digest builder. */
|
|
22
|
+
defaultDigestModel: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const PROVIDERS: Record<ProviderName, ProviderInfo> = {
|
|
26
|
+
anthropic: {
|
|
27
|
+
name: 'anthropic',
|
|
28
|
+
label: 'Anthropic',
|
|
29
|
+
envKey: 'ANTHROPIC_API_KEY',
|
|
30
|
+
defaultModel: 'claude-haiku-4-5',
|
|
31
|
+
defaultDigestModel: 'claude-opus-4-8',
|
|
32
|
+
},
|
|
33
|
+
openai: {
|
|
34
|
+
name: 'openai',
|
|
35
|
+
label: 'OpenAI',
|
|
36
|
+
envKey: 'OPENAI_API_KEY',
|
|
37
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
38
|
+
defaultModel: 'gpt-4.1-mini',
|
|
39
|
+
defaultDigestModel: 'gpt-5.1',
|
|
40
|
+
},
|
|
41
|
+
openrouter: {
|
|
42
|
+
name: 'openrouter',
|
|
43
|
+
label: 'OpenRouter',
|
|
44
|
+
envKey: 'OPENROUTER_API_KEY',
|
|
45
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
46
|
+
defaultModel: 'anthropic/claude-haiku-4.5',
|
|
47
|
+
defaultDigestModel: 'anthropic/claude-opus-4.8',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Validates a configured provider name, defaulting to `anthropic`. */
|
|
52
|
+
export function resolveProviderName(value?: string): ProviderName {
|
|
53
|
+
if (!value) return 'anthropic';
|
|
54
|
+
if (value in PROVIDERS) return value as ProviderName;
|
|
55
|
+
throw new Error(`Unknown provider "${value}". Expected one of: ${Object.keys(PROVIDERS).join(', ')}.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LlmClient {
|
|
59
|
+
call: typeof callClaude;
|
|
60
|
+
stream: typeof streamClaude;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns the call/stream pair for a provider. `baseUrl` overrides the
|
|
65
|
+
* provider's API base, so any Chat Completions-compatible endpoint works.
|
|
66
|
+
*/
|
|
67
|
+
export function clientFor(provider: ProviderName, baseUrl?: string): LlmClient {
|
|
68
|
+
if (provider === 'anthropic') return { call: callClaude, stream: streamClaude };
|
|
69
|
+
|
|
70
|
+
const info = PROVIDERS[provider];
|
|
71
|
+
const endpoint: OpenAiEndpoint = {
|
|
72
|
+
baseUrl: baseUrl ?? info.baseUrl!,
|
|
73
|
+
// OpenAI's reasoning models reject `max_tokens`; OpenRouter normalizes it.
|
|
74
|
+
tokenParam: provider === 'openai' ? 'max_completion_tokens' : 'max_tokens',
|
|
75
|
+
label: info.label,
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
call: (opts) => callOpenAi(opts, endpoint),
|
|
79
|
+
stream: (opts) => streamOpenAi(opts, endpoint),
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Inference providers the search loop and digest builder can run against. */
|
|
2
|
+
export type ProviderName = 'anthropic' | 'openai' | 'openrouter';
|
|
3
|
+
|
|
1
4
|
export interface HevAskOptions {
|
|
2
5
|
/**
|
|
3
6
|
* Content collection name(s) to index and search over.
|
|
@@ -6,8 +9,24 @@ export interface HevAskOptions {
|
|
|
6
9
|
collections?: string[];
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* Inference provider for the agentic loop and the digest builder. Each
|
|
13
|
+
* provider reads its own key from the environment: `ANTHROPIC_API_KEY`,
|
|
14
|
+
* `OPENAI_API_KEY`, or `OPENROUTER_API_KEY`.
|
|
15
|
+
* @default 'anthropic'
|
|
16
|
+
*/
|
|
17
|
+
provider?: ProviderName;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Override the provider's API base URL. Applies to the OpenAI-compatible
|
|
21
|
+
* providers only, so any Chat Completions-compatible endpoint works
|
|
22
|
+
* (e.g. a proxy or a self-hosted gateway).
|
|
23
|
+
*/
|
|
24
|
+
providerBaseUrl?: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Model used by the bounded search loop. Defaults per provider:
|
|
28
|
+
* `claude-haiku-4-5` (anthropic), `gpt-4.1-mini` (openai),
|
|
29
|
+
* `anthropic/claude-haiku-4.5` (openrouter).
|
|
11
30
|
*/
|
|
12
31
|
model?: string;
|
|
13
32
|
|
|
@@ -38,8 +57,9 @@ export interface HevAskOptions {
|
|
|
38
57
|
answerMaxTokens?: number;
|
|
39
58
|
|
|
40
59
|
/**
|
|
41
|
-
* Model used by the offline digest builder.
|
|
42
|
-
*
|
|
60
|
+
* Model used by the offline digest builder. Defaults per provider:
|
|
61
|
+
* `claude-opus-4-8` (anthropic), `gpt-5.1` (openai),
|
|
62
|
+
* `anthropic/claude-opus-4.8` (openrouter).
|
|
43
63
|
*/
|
|
44
64
|
digestModel?: string;
|
|
45
65
|
|
|
@@ -69,8 +89,14 @@ export interface HevAskOptions {
|
|
|
69
89
|
perDocCap?: number;
|
|
70
90
|
|
|
71
91
|
/**
|
|
72
|
-
* Path to the committed ask digest
|
|
73
|
-
* @default '.hev-ask
|
|
92
|
+
* Path to the committed ask digest tree, relative to the site root.
|
|
93
|
+
* @default '.hev-ask'
|
|
94
|
+
*/
|
|
95
|
+
digestDir?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Deprecated alias for `digestDir`.
|
|
99
|
+
* @default '.hev-ask'
|
|
74
100
|
*/
|
|
75
101
|
digestPath?: string;
|
|
76
102
|
|
|
@@ -84,6 +110,8 @@ export interface HevAskOptions {
|
|
|
84
110
|
/** The shape the integration serializes into `virtual:hev-ask/config`. */
|
|
85
111
|
export interface ResolvedConfig {
|
|
86
112
|
collections: string[] | null;
|
|
113
|
+
provider: ProviderName;
|
|
114
|
+
providerBaseUrl?: string;
|
|
87
115
|
model: string;
|
|
88
116
|
digestModel: string;
|
|
89
117
|
endpoint: string;
|