@jxburros/ai-nugget 0.3.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.
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/adapters/engines/anthropic.d.ts +8 -0
- package/dist/adapters/engines/anthropic.js +278 -0
- package/dist/adapters/engines/base.d.ts +23 -0
- package/dist/adapters/engines/base.js +93 -0
- package/dist/adapters/engines/google.d.ts +8 -0
- package/dist/adapters/engines/google.js +217 -0
- package/dist/adapters/engines/ollama.d.ts +10 -0
- package/dist/adapters/engines/ollama.js +160 -0
- package/dist/adapters/engines/openaiChat.d.ts +15 -0
- package/dist/adapters/engines/openaiChat.js +228 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.js +17 -0
- package/dist/adapters/profiles.d.ts +42 -0
- package/dist/adapters/profiles.js +111 -0
- package/dist/agent/index.d.ts +3 -0
- package/dist/agent/index.js +3 -0
- package/dist/agent/loop.d.ts +70 -0
- package/dist/agent/loop.js +263 -0
- package/dist/agent/tools.d.ts +29 -0
- package/dist/agent/tools.js +48 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +80 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.js +371 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json.d.ts +7 -0
- package/dist/json.js +114 -0
- package/dist/keys.d.ts +7 -0
- package/dist/keys.js +78 -0
- package/dist/policy.d.ts +6 -0
- package/dist/policy.js +37 -0
- package/dist/redact.d.ts +8 -0
- package/dist/redact.js +46 -0
- package/dist/tokens.d.ts +5 -0
- package/dist/tokens.js +18 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +145 -0
- package/dist/types.d.ts +191 -0
- package/dist/types.js +4 -0
- package/dist/util.d.ts +8 -0
- package/dist/util.js +51 -0
- package/nugget/VERSION.txt +2 -0
- package/nugget/src/adapters/engines/anthropic.ts +260 -0
- package/nugget/src/adapters/engines/base.ts +90 -0
- package/nugget/src/adapters/engines/google.ts +209 -0
- package/nugget/src/adapters/engines/ollama.ts +153 -0
- package/nugget/src/adapters/engines/openaiChat.ts +223 -0
- package/nugget/src/adapters/index.ts +17 -0
- package/nugget/src/adapters/profiles.ts +149 -0
- package/nugget/src/agent/index.ts +2 -0
- package/nugget/src/agent/loop.ts +314 -0
- package/nugget/src/agent/tools.ts +55 -0
- package/nugget/src/errors.ts +80 -0
- package/nugget/src/handler.ts +408 -0
- package/nugget/src/index.ts +10 -0
- package/nugget/src/json.ts +108 -0
- package/nugget/src/keys.ts +70 -0
- package/nugget/src/policy.ts +39 -0
- package/nugget/src/redact.ts +48 -0
- package/nugget/src/tokens.ts +21 -0
- package/nugget/src/transport.ts +148 -0
- package/nugget/src/types.ts +175 -0
- package/nugget/src/util.ts +51 -0
- package/package.json +69 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { AIError } from '../../errors.js';
|
|
2
|
+
import { estimatedUsage } from '../../tokens.js';
|
|
3
|
+
import { postResponse, sseLines } from '../../transport.js';
|
|
4
|
+
import type { ChatMessage, ChatRequest, ChatResult, ProviderAdapter, ResolvedConnection, StreamEvent, ToolCall } from '../../types.js';
|
|
5
|
+
import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
|
|
6
|
+
import { streamError, streamTimeout } from './base.js';
|
|
7
|
+
|
|
8
|
+
const JSON_MODE_TOOL = 'json_output';
|
|
9
|
+
|
|
10
|
+
export class AnthropicAdapter implements ProviderAdapter {
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
constructor(provider: string) {
|
|
13
|
+
this.provider = provider;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult> {
|
|
17
|
+
let result: ChatResult | undefined;
|
|
18
|
+
for await (const event of this.stream(conn, req)) {
|
|
19
|
+
if (event.type === 'done') result = event.result;
|
|
20
|
+
if (event.type === 'error') throw event.error;
|
|
21
|
+
}
|
|
22
|
+
if (!result) throw new AIError('Anthropic stream ended without a result', { kind: 'invalid_response', provider: conn.provider });
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async *stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent> {
|
|
27
|
+
const started = Date.now();
|
|
28
|
+
const jsonMode = req.responseFormat?.type === 'json' && !req.tools?.length;
|
|
29
|
+
let firstTokenMs: number | null = null;
|
|
30
|
+
let text = '';
|
|
31
|
+
let inputTokens: number | undefined;
|
|
32
|
+
let outputTokens: number | undefined;
|
|
33
|
+
let stopReason: string | undefined;
|
|
34
|
+
const emittedTools: ToolCall[] = [];
|
|
35
|
+
// Partial tool_use blocks keyed by content-block index (input_json_delta accumulation).
|
|
36
|
+
const blocks = new Map<number, { id: string; name: string; raw: string }>();
|
|
37
|
+
const timeout = streamTimeout(conn, req.signal);
|
|
38
|
+
yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
|
|
39
|
+
try {
|
|
40
|
+
const res = await postResponse(`${conn.baseUrl}/v1/messages`, body(req, jsonMode), conn.headers, timeout.signal, conn.provider);
|
|
41
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
42
|
+
if (!contentType.includes('text/event-stream')) {
|
|
43
|
+
const data = await res.json() as unknown;
|
|
44
|
+
const parsed = parseResponse(data);
|
|
45
|
+
inputTokens = parsed.inputTokens;
|
|
46
|
+
outputTokens = parsed.outputTokens;
|
|
47
|
+
stopReason = parsed.stopReason;
|
|
48
|
+
if (jsonMode) {
|
|
49
|
+
text = jsonTextFrom(parsed.toolCalls, parsed.text);
|
|
50
|
+
if (text) yield { type: 'delta', text };
|
|
51
|
+
} else {
|
|
52
|
+
text = parsed.text;
|
|
53
|
+
if (text) yield { type: 'delta', text };
|
|
54
|
+
for (const call of parsed.toolCalls) {
|
|
55
|
+
emittedTools.push(call);
|
|
56
|
+
yield { type: 'tool_call', call };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
for await (const line of sseLines(res)) {
|
|
61
|
+
const record = asRecord(safeParse(line));
|
|
62
|
+
if (!record) continue;
|
|
63
|
+
const type = asString(record.type);
|
|
64
|
+
if (type === 'message_start') {
|
|
65
|
+
const usage = asRecord(asRecord(record.message)?.usage);
|
|
66
|
+
inputTokens = asNumber(usage?.input_tokens) ?? inputTokens;
|
|
67
|
+
outputTokens = asNumber(usage?.output_tokens) ?? outputTokens;
|
|
68
|
+
} else if (type === 'content_block_start') {
|
|
69
|
+
const index = asNumber(record.index) ?? 0;
|
|
70
|
+
const block = asRecord(record.content_block);
|
|
71
|
+
if (block?.type === 'tool_use') {
|
|
72
|
+
blocks.set(index, { id: asString(block.id) ?? randomId(), name: asString(block.name) ?? 'unknown', raw: '' });
|
|
73
|
+
}
|
|
74
|
+
} else if (type === 'content_block_delta') {
|
|
75
|
+
const index = asNumber(record.index) ?? 0;
|
|
76
|
+
const delta = asRecord(record.delta);
|
|
77
|
+
if (delta?.type === 'text_delta') {
|
|
78
|
+
const piece = asString(delta.text) ?? '';
|
|
79
|
+
if (piece) {
|
|
80
|
+
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
|
81
|
+
text += piece;
|
|
82
|
+
yield { type: 'delta', text: piece };
|
|
83
|
+
}
|
|
84
|
+
} else if (delta?.type === 'input_json_delta') {
|
|
85
|
+
const partial = blocks.get(index);
|
|
86
|
+
if (partial) partial.raw += asString(delta.partial_json) ?? '';
|
|
87
|
+
}
|
|
88
|
+
} else if (type === 'content_block_stop') {
|
|
89
|
+
const index = asNumber(record.index) ?? 0;
|
|
90
|
+
const partial = blocks.get(index);
|
|
91
|
+
if (partial) {
|
|
92
|
+
const call: ToolCall = { id: partial.id, name: partial.name, raw: partial.raw, arguments: parseArgs(partial.raw) };
|
|
93
|
+
blocks.delete(index);
|
|
94
|
+
if (jsonMode && call.name === JSON_MODE_TOOL) {
|
|
95
|
+
text = jsonTextFrom([call], text);
|
|
96
|
+
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
|
97
|
+
yield { type: 'delta', text };
|
|
98
|
+
} else {
|
|
99
|
+
emittedTools.push(call);
|
|
100
|
+
yield { type: 'tool_call', call };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else if (type === 'message_delta') {
|
|
104
|
+
const delta = asRecord(record.delta);
|
|
105
|
+
stopReason = asString(delta?.stop_reason) ?? stopReason;
|
|
106
|
+
const usage = asRecord(record.usage);
|
|
107
|
+
outputTokens = asNumber(usage?.output_tokens) ?? outputTokens;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const hasTools = emittedTools.length > 0;
|
|
112
|
+
yield { type: 'done', result: {
|
|
113
|
+
text,
|
|
114
|
+
toolCalls: hasTools ? emittedTools : undefined,
|
|
115
|
+
finishReason: mapStop(stopReason, hasTools),
|
|
116
|
+
usage: inputTokens !== undefined || outputTokens !== undefined ? { inputTokens, outputTokens, estimated: false } : estimatedUsage(textFromMessages(req.messages), text),
|
|
117
|
+
timing: { firstTokenMs, totalMs: Date.now() - started },
|
|
118
|
+
model: req.model,
|
|
119
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
120
|
+
} };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw streamError(error, timeout, conn.provider);
|
|
123
|
+
} finally {
|
|
124
|
+
timeout.done();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function body(req: ChatRequest, jsonMode: boolean): Record<string, unknown> {
|
|
130
|
+
const system = req.messages.filter((m) => m.role === 'system').map((m) => textContent(m.content)).join('\n\n') || undefined;
|
|
131
|
+
const base: Record<string, unknown> = {
|
|
132
|
+
model: req.model,
|
|
133
|
+
max_tokens: req.maxTokens ?? 4096,
|
|
134
|
+
temperature: req.temperature,
|
|
135
|
+
top_p: req.topP,
|
|
136
|
+
stop_sequences: req.stopSequences,
|
|
137
|
+
system,
|
|
138
|
+
messages: toAnthropicMessages(req.messages.filter((m) => m.role !== 'system')),
|
|
139
|
+
stream: true,
|
|
140
|
+
};
|
|
141
|
+
if (jsonMode) {
|
|
142
|
+
// Forced-tool JSON mode: Anthropic has no response_format, so we make the
|
|
143
|
+
// model answer through a single required tool whose input is the JSON we want.
|
|
144
|
+
base.tools = [{ name: JSON_MODE_TOOL, description: 'Return the answer as a JSON object.', input_schema: schemaFor(req) }];
|
|
145
|
+
base.tool_choice = { type: 'tool', name: JSON_MODE_TOOL };
|
|
146
|
+
} else if (req.tools?.length) {
|
|
147
|
+
base.tools = req.tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters }));
|
|
148
|
+
if (typeof req.toolChoice === 'object') base.tool_choice = { type: 'tool', name: req.toolChoice.name };
|
|
149
|
+
else if (req.toolChoice === 'auto') base.tool_choice = { type: 'auto' };
|
|
150
|
+
else if (req.toolChoice === 'none') base.tool_choice = { type: 'none' };
|
|
151
|
+
}
|
|
152
|
+
return base;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toAnthropicMessages(messages: ChatMessage[]): Record<string, unknown>[] {
|
|
156
|
+
const out: Record<string, unknown>[] = [];
|
|
157
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
158
|
+
const message = messages[index]!;
|
|
159
|
+
if (message.role !== 'tool') {
|
|
160
|
+
out.push(toAnthropicMessage(message));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const content: unknown[] = [];
|
|
164
|
+
while (index < messages.length && messages[index]?.role === 'tool') {
|
|
165
|
+
const toolMessage = messages[index]!;
|
|
166
|
+
content.push({ type: 'tool_result', tool_use_id: toolMessage.toolCallId ?? '', content: textContent(toolMessage.content) });
|
|
167
|
+
index += 1;
|
|
168
|
+
}
|
|
169
|
+
index -= 1;
|
|
170
|
+
out.push({ role: 'user', content });
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toAnthropicMessage(m: ChatMessage): Record<string, unknown> {
|
|
176
|
+
// tool result messages map to a user turn carrying a tool_result content block.
|
|
177
|
+
if (m.role === 'tool') {
|
|
178
|
+
return {
|
|
179
|
+
role: 'user',
|
|
180
|
+
content: [{ type: 'tool_result', tool_use_id: m.toolCallId ?? '', content: typeof m.content === 'string' ? m.content : '' }],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// assistant turns that carried tool calls replay them as tool_use blocks.
|
|
184
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
185
|
+
const content: unknown[] = [];
|
|
186
|
+
if (typeof m.content === 'string' && m.content) content.push({ type: 'text', text: m.content });
|
|
187
|
+
for (const call of m.toolCalls) content.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments ?? {} });
|
|
188
|
+
return { role: 'assistant', content };
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
192
|
+
content: typeof m.content === 'string' ? m.content : m.content.map((part) => part.type === 'image'
|
|
193
|
+
? { type: 'image', source: { type: 'base64', media_type: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
|
|
194
|
+
: { type: 'text', text: part.text ?? '' }),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function textContent(content: ChatMessage['content']): string {
|
|
199
|
+
if (typeof content === 'string') return content;
|
|
200
|
+
if (!content) return '';
|
|
201
|
+
return content.filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseResponse(data: unknown): { text: string; inputTokens?: number; outputTokens?: number; stopReason?: string; toolCalls: ToolCall[] } {
|
|
205
|
+
const record = asRecord(data);
|
|
206
|
+
const content = Array.isArray(record?.content) ? record.content : [];
|
|
207
|
+
let text = '';
|
|
208
|
+
const toolCalls: ToolCall[] = [];
|
|
209
|
+
for (const blockValue of content) {
|
|
210
|
+
const block = asRecord(blockValue);
|
|
211
|
+
if (block?.type === 'text') text += asString(block.text) ?? '';
|
|
212
|
+
if (block?.type === 'tool_use') {
|
|
213
|
+
toolCalls.push({
|
|
214
|
+
id: asString(block.id) ?? randomId(),
|
|
215
|
+
name: asString(block.name) ?? 'unknown',
|
|
216
|
+
arguments: block.input ?? {},
|
|
217
|
+
raw: JSON.stringify(block.input ?? {}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const usage = asRecord(record?.usage);
|
|
222
|
+
return { text, inputTokens: asNumber(usage?.input_tokens), outputTokens: asNumber(usage?.output_tokens), stopReason: asString(record?.stop_reason), toolCalls };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function schemaFor(req: ChatRequest): object {
|
|
226
|
+
const schema = req.responseFormat?.type === 'json' ? req.responseFormat.schema : undefined;
|
|
227
|
+
return schema ?? { type: 'object' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function jsonTextFrom(toolCalls: ToolCall[], fallback: string): string {
|
|
231
|
+
const json = toolCalls.find((call) => call.name === JSON_MODE_TOOL) ?? toolCalls[0];
|
|
232
|
+
if (json) return json.raw && json.raw.trim() ? json.raw : JSON.stringify(json.arguments ?? {});
|
|
233
|
+
return fallback;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function mapStop(stop: string | undefined, hasTools: boolean): ChatResult['finishReason'] {
|
|
237
|
+
if (stop === 'tool_use' && hasTools) return 'tool';
|
|
238
|
+
if (stop === 'max_tokens') return 'length';
|
|
239
|
+
return 'stop';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseArgs(raw: string): unknown {
|
|
243
|
+
try {
|
|
244
|
+
return raw ? JSON.parse(raw) as unknown : {};
|
|
245
|
+
} catch {
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function safeParse(line: string): unknown {
|
|
251
|
+
try {
|
|
252
|
+
return JSON.parse(line) as unknown;
|
|
253
|
+
} catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function randomId(): string {
|
|
259
|
+
return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
260
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { AIError, fromUnknown } from '../../errors.js';
|
|
2
|
+
import { fetchJson, withTimeout } from '../../transport.js';
|
|
3
|
+
import type { ModelInfo, ResolvedConnection } from '../../types.js';
|
|
4
|
+
import { asRecord, asString } from '../../util.js';
|
|
5
|
+
import type { ProviderProfile } from '../profiles.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Maps a raw streaming failure onto a typed {@link AIError}. A fired timeout is
|
|
11
|
+
* reported as `timeout` regardless of how the underlying `fetch` surfaced the
|
|
12
|
+
* abort; anything else is normalized through {@link fromUnknown} (which passes
|
|
13
|
+
* an existing {@link AIError} through untouched, preserving retryability).
|
|
14
|
+
*/
|
|
15
|
+
export function streamError(error: unknown, timeout: { timedOut(): boolean }, provider: string): AIError {
|
|
16
|
+
if (timeout.timedOut()) return new AIError('Request timed out', { kind: 'timeout', provider });
|
|
17
|
+
return fromUnknown(error, provider);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Create a timeout/abort scope covering the full stream lifetime. */
|
|
21
|
+
export function streamTimeout(conn: ResolvedConnection, signal?: AbortSignal): ReturnType<typeof withTimeout> {
|
|
22
|
+
return withTimeout(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listOpenModels(conn: ResolvedConnection, profile: ProviderProfile): Promise<ModelInfo[]> {
|
|
26
|
+
if (!profile.listModelsPath) return [];
|
|
27
|
+
const { data } = await fetchJson(`${conn.baseUrl}${profile.listModelsPath}`, {
|
|
28
|
+
method: 'GET',
|
|
29
|
+
headers: conn.headers,
|
|
30
|
+
timeoutMs: conn.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
31
|
+
provider: conn.provider,
|
|
32
|
+
});
|
|
33
|
+
const record = asRecord(data);
|
|
34
|
+
const items = Array.isArray(record?.data) ? record.data : Array.isArray(record?.models) ? record.models : [];
|
|
35
|
+
const models: ModelInfo[] = [];
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
const row = asRecord(item);
|
|
38
|
+
const id = asString(row?.id) ?? asString(row?.name);
|
|
39
|
+
if (!id) continue;
|
|
40
|
+
const contextWindow = asContextWindow(row);
|
|
41
|
+
const capabilities = asCapabilities(row);
|
|
42
|
+
const model: ModelInfo = {
|
|
43
|
+
id,
|
|
44
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
45
|
+
};
|
|
46
|
+
if (contextWindow !== undefined) model.contextWindow = contextWindow;
|
|
47
|
+
if (capabilities) model.capabilities = capabilities;
|
|
48
|
+
models.push(model);
|
|
49
|
+
}
|
|
50
|
+
return models;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function health(conn: ResolvedConnection, profile: ProviderProfile): Promise<{ ok: boolean; detail?: string }> {
|
|
54
|
+
const path = profile.healthPath ?? profile.listModelsPath;
|
|
55
|
+
if (!path) return { ok: true };
|
|
56
|
+
try {
|
|
57
|
+
await fetchJson(`${conn.baseUrl}${path}`, {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: conn.headers,
|
|
60
|
+
timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 10_000),
|
|
61
|
+
provider: conn.provider,
|
|
62
|
+
});
|
|
63
|
+
return { ok: true };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { ok: false, detail: error instanceof Error ? error.message : 'Health check failed' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function requireResponse(condition: unknown, message: string): asserts condition {
|
|
70
|
+
if (!condition) throw new AIError(message, { kind: 'invalid_response' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pulls a context-window figure out of the many shapes providers use for it
|
|
75
|
+
* (OpenRouter `context_length`, OpenAI-style `context_window`, Ollama
|
|
76
|
+
* `/api/show` `model_info` entries surfaced as `contextWindow`).
|
|
77
|
+
*/
|
|
78
|
+
function asContextWindow(row: Record<string, unknown> | undefined): number | undefined {
|
|
79
|
+
if (!row) return undefined;
|
|
80
|
+
const direct = row['context_length'] ?? row['context_window'] ?? row['contextWindow'];
|
|
81
|
+
return typeof direct === 'number' && Number.isFinite(direct) ? direct : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function asCapabilities(row: Record<string, unknown> | undefined): string[] | undefined {
|
|
85
|
+
if (!row) return undefined;
|
|
86
|
+
const value = row['capabilities'] ?? asRecord(row['architecture'])?.['modality'];
|
|
87
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) return value as string[];
|
|
88
|
+
if (typeof value === 'string') return [value];
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { AIError } from '../../errors.js';
|
|
2
|
+
import { estimatedUsage } from '../../tokens.js';
|
|
3
|
+
import { postResponse, sseLines } from '../../transport.js';
|
|
4
|
+
import type { ChatMessage, ChatRequest, ChatResult, ProviderAdapter, ResolvedConnection, StreamEvent, ToolCall } from '../../types.js';
|
|
5
|
+
import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
|
|
6
|
+
import { streamError, streamTimeout } from './base.js';
|
|
7
|
+
|
|
8
|
+
export class GoogleAdapter implements ProviderAdapter {
|
|
9
|
+
readonly provider: string;
|
|
10
|
+
constructor(provider: string) {
|
|
11
|
+
this.provider = provider;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult> {
|
|
15
|
+
let result: ChatResult | undefined;
|
|
16
|
+
for await (const event of this.stream(conn, req)) {
|
|
17
|
+
if (event.type === 'done') result = event.result;
|
|
18
|
+
if (event.type === 'error') throw event.error;
|
|
19
|
+
}
|
|
20
|
+
if (!result) throw new AIError('Google stream ended without a result', { kind: 'invalid_response', provider: conn.provider });
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async *stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent> {
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
let firstTokenMs: number | null = null;
|
|
27
|
+
let text = '';
|
|
28
|
+
let inputTokens: number | undefined;
|
|
29
|
+
let outputTokens: number | undefined;
|
|
30
|
+
let finish: string | undefined;
|
|
31
|
+
const toolCalls: ToolCall[] = [];
|
|
32
|
+
const timeout = streamTimeout(conn, req.signal);
|
|
33
|
+
yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
|
|
34
|
+
try {
|
|
35
|
+
const streamUrl = `${conn.baseUrl}/v1beta/models/${encodeURIComponent(req.model)}:streamGenerateContent?alt=sse`;
|
|
36
|
+
const res = await postResponse(streamUrl, body(req), conn.headers, timeout.signal, conn.provider);
|
|
37
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
38
|
+
const chunks = contentType.includes('text/event-stream') ? sseLines(res) : singleJsonLine(res);
|
|
39
|
+
for await (const line of chunks) {
|
|
40
|
+
const parsed = safeParse(line);
|
|
41
|
+
const record = asRecord(parsed);
|
|
42
|
+
const promptFeedback = asRecord(record?.promptFeedback);
|
|
43
|
+
if (!firstCandidate(record) && promptFeedback?.blockReason) finish = 'SAFETY';
|
|
44
|
+
for (const part of candidateParts(record)) {
|
|
45
|
+
const partText = asString(asRecord(part)?.text);
|
|
46
|
+
if (partText) {
|
|
47
|
+
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
|
48
|
+
text += partText;
|
|
49
|
+
yield { type: 'delta', text: partText };
|
|
50
|
+
}
|
|
51
|
+
const fnCall = asRecord(asRecord(part)?.functionCall);
|
|
52
|
+
if (fnCall) {
|
|
53
|
+
const call: ToolCall = {
|
|
54
|
+
id: randomId(),
|
|
55
|
+
name: asString(fnCall.name) ?? 'unknown',
|
|
56
|
+
arguments: fnCall.args ?? {},
|
|
57
|
+
raw: JSON.stringify(fnCall.args ?? {}),
|
|
58
|
+
};
|
|
59
|
+
toolCalls.push(call);
|
|
60
|
+
yield { type: 'tool_call', call };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
finish = asString(asRecord(firstCandidate(record))?.finishReason) ?? finish;
|
|
64
|
+
const usage = asRecord(record?.usageMetadata);
|
|
65
|
+
inputTokens = asNumber(usage?.promptTokenCount) ?? inputTokens;
|
|
66
|
+
outputTokens = asNumber(usage?.candidatesTokenCount) ?? outputTokens;
|
|
67
|
+
}
|
|
68
|
+
const hasTools = toolCalls.length > 0;
|
|
69
|
+
yield { type: 'done', result: {
|
|
70
|
+
text,
|
|
71
|
+
toolCalls: hasTools ? toolCalls : undefined,
|
|
72
|
+
finishReason: mapFinish(finish, hasTools),
|
|
73
|
+
usage: inputTokens !== undefined || outputTokens !== undefined ? { inputTokens, outputTokens, estimated: false } : estimatedUsage(textFromMessages(req.messages), text),
|
|
74
|
+
timing: { firstTokenMs, totalMs: Date.now() - started },
|
|
75
|
+
model: req.model,
|
|
76
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
77
|
+
} };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw streamError(error, timeout, conn.provider);
|
|
80
|
+
} finally {
|
|
81
|
+
timeout.done();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function body(req: ChatRequest): Record<string, unknown> {
|
|
87
|
+
const systemText = req.messages.filter((m) => m.role === 'system').map((m) => textContent(m.content)).join('\n\n');
|
|
88
|
+
const jsonMode = req.responseFormat?.type === 'json' && !req.tools?.length;
|
|
89
|
+
const responseSchema = req.responseFormat?.type === 'json' ? req.responseFormat.schema : undefined;
|
|
90
|
+
const payload: Record<string, unknown> = {
|
|
91
|
+
contents: toGoogleContents(req.messages.filter((m) => m.role !== 'system')),
|
|
92
|
+
generationConfig: {
|
|
93
|
+
temperature: req.temperature,
|
|
94
|
+
topP: req.topP,
|
|
95
|
+
maxOutputTokens: req.maxTokens,
|
|
96
|
+
stopSequences: req.stopSequences,
|
|
97
|
+
responseMimeType: jsonMode ? 'application/json' : undefined,
|
|
98
|
+
responseSchema: jsonMode ? responseSchema : undefined,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
if (systemText) payload.systemInstruction = { parts: [{ text: systemText }] };
|
|
102
|
+
if (req.tools?.length) {
|
|
103
|
+
payload.tools = [{ functionDeclarations: req.tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters })) }];
|
|
104
|
+
const mode = req.toolChoice === 'none' ? 'NONE' : typeof req.toolChoice === 'object' ? 'ANY' : 'AUTO';
|
|
105
|
+
const config: Record<string, unknown> = { mode };
|
|
106
|
+
if (typeof req.toolChoice === 'object') config.allowedFunctionNames = [req.toolChoice.name];
|
|
107
|
+
payload.toolConfig = { functionCallingConfig: config };
|
|
108
|
+
}
|
|
109
|
+
return payload;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toGoogleContents(messages: ChatMessage[]): Record<string, unknown>[] {
|
|
113
|
+
const contents: Record<string, unknown>[] = [];
|
|
114
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
115
|
+
const message = messages[index]!;
|
|
116
|
+
if (message.role !== 'tool') {
|
|
117
|
+
contents.push(toGoogleContent(message));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const parts: unknown[] = [];
|
|
121
|
+
while (index < messages.length && messages[index]?.role === 'tool') {
|
|
122
|
+
const toolMessage = messages[index]!;
|
|
123
|
+
parts.push({ functionResponse: { name: toolMessage.name ?? 'unknown', response: toolResponseObject(toolMessage) } });
|
|
124
|
+
index += 1;
|
|
125
|
+
}
|
|
126
|
+
index -= 1;
|
|
127
|
+
contents.push({ role: 'user', parts });
|
|
128
|
+
}
|
|
129
|
+
return contents;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function toGoogleContent(m: ChatMessage): Record<string, unknown> {
|
|
133
|
+
if (m.role === 'tool') {
|
|
134
|
+
return {
|
|
135
|
+
role: 'user',
|
|
136
|
+
parts: [{ functionResponse: { name: m.name ?? 'unknown', response: toolResponseObject(m) } }],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
140
|
+
const parts: unknown[] = [];
|
|
141
|
+
if (typeof m.content === 'string' && m.content) parts.push({ text: m.content });
|
|
142
|
+
for (const call of m.toolCalls) parts.push({ functionCall: { name: call.name, args: call.arguments ?? {} } });
|
|
143
|
+
return { role: 'model', parts };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
147
|
+
parts: typeof m.content === 'string'
|
|
148
|
+
? [{ text: m.content }]
|
|
149
|
+
: m.content.map((part) => part.type === 'image'
|
|
150
|
+
? { inlineData: { mimeType: part.mimeType ?? 'image/png', data: part.imageBase64 ?? '' } }
|
|
151
|
+
: { text: part.text ?? '' }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function textContent(content: ChatMessage['content']): string {
|
|
156
|
+
if (typeof content === 'string') return content;
|
|
157
|
+
if (!content) return '';
|
|
158
|
+
return content.filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toolResponseObject(message: ChatMessage): Record<string, unknown> {
|
|
162
|
+
if (typeof message.content !== 'string') return { content: textContent(message.content) };
|
|
163
|
+
try {
|
|
164
|
+
const parsed: unknown = JSON.parse(message.content);
|
|
165
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
166
|
+
} catch {
|
|
167
|
+
// keep string fallback
|
|
168
|
+
}
|
|
169
|
+
return { content: message.content };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function* singleJsonLine(res: Response): AsyncIterable<string> {
|
|
173
|
+
const data = await res.json() as unknown;
|
|
174
|
+
// Non-SSE responses may be a single object or an array of streamed chunks.
|
|
175
|
+
if (Array.isArray(data)) {
|
|
176
|
+
for (const item of data) yield JSON.stringify(item);
|
|
177
|
+
} else {
|
|
178
|
+
yield JSON.stringify(data);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function firstCandidate(record: Record<string, unknown> | undefined): unknown {
|
|
183
|
+
const candidates = Array.isArray(record?.candidates) ? record.candidates : [];
|
|
184
|
+
return candidates[0];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function candidateParts(record: Record<string, unknown> | undefined): unknown[] {
|
|
188
|
+
const content = asRecord(asRecord(firstCandidate(record))?.content);
|
|
189
|
+
return Array.isArray(content?.parts) ? content.parts : [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mapFinish(finish: string | undefined, hasTools: boolean): ChatResult['finishReason'] {
|
|
193
|
+
if (hasTools) return 'tool';
|
|
194
|
+
if (finish === 'MAX_TOKENS') return 'length';
|
|
195
|
+
if (finish === 'SAFETY' || finish === 'RECITATION' || finish === 'BLOCKLIST' || finish === 'PROHIBITED_CONTENT') return 'content_filter';
|
|
196
|
+
return 'stop';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function safeParse(line: string): unknown {
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(line) as unknown;
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function randomId(): string {
|
|
208
|
+
return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
209
|
+
}
|