@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,153 @@
|
|
|
1
|
+
import { AIError } from '../../errors.js';
|
|
2
|
+
import { estimatedUsage } from '../../tokens.js';
|
|
3
|
+
import { fetchJson, ndjsonLines, postResponse } from '../../transport.js';
|
|
4
|
+
import type { ChatMessage, ChatRequest, ChatResult, ModelInfo, ProviderAdapter, ResolvedConnection, StreamEvent, ToolCall } from '../../types.js';
|
|
5
|
+
import { asNumber, asRecord, asString, textFromMessages } from '../../util.js';
|
|
6
|
+
import { DEFAULT_TIMEOUT_MS, streamError, streamTimeout } from './base.js';
|
|
7
|
+
|
|
8
|
+
export class OllamaAdapter 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('Ollama 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 doneReason: 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 res = await postResponse(`${conn.baseUrl}/api/chat`, body(req), conn.headers, timeout.signal, conn.provider);
|
|
36
|
+
for await (const value of ndjsonLines(res)) {
|
|
37
|
+
const record = asRecord(value);
|
|
38
|
+
const message = asRecord(record?.message);
|
|
39
|
+
const piece = asString(message?.content);
|
|
40
|
+
if (piece) {
|
|
41
|
+
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
|
42
|
+
text += piece;
|
|
43
|
+
yield { type: 'delta', text: piece };
|
|
44
|
+
}
|
|
45
|
+
inputTokens = asNumber(record?.prompt_eval_count) ?? inputTokens;
|
|
46
|
+
outputTokens = asNumber(record?.eval_count) ?? outputTokens;
|
|
47
|
+
doneReason = asString(record?.done_reason) ?? doneReason;
|
|
48
|
+
const calls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
|
|
49
|
+
for (const callValue of calls) {
|
|
50
|
+
const fn = asRecord(asRecord(callValue)?.function);
|
|
51
|
+
const args = fn?.arguments ?? {};
|
|
52
|
+
const toolCall: ToolCall = {
|
|
53
|
+
id: randomId(),
|
|
54
|
+
name: asString(fn?.name) ?? 'unknown',
|
|
55
|
+
arguments: args,
|
|
56
|
+
raw: typeof args === 'string' ? args : JSON.stringify(args),
|
|
57
|
+
};
|
|
58
|
+
toolCalls.push(toolCall);
|
|
59
|
+
yield { type: 'tool_call', call: toolCall };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const hasTools = toolCalls.length > 0;
|
|
63
|
+
yield { type: 'done', result: {
|
|
64
|
+
text,
|
|
65
|
+
toolCalls: hasTools ? toolCalls : undefined,
|
|
66
|
+
finishReason: hasTools ? 'tool' : doneReason === 'length' ? 'length' : 'stop',
|
|
67
|
+
usage: inputTokens !== undefined || outputTokens !== undefined ? { inputTokens, outputTokens, estimated: false } : estimatedUsage(textFromMessages(req.messages), text),
|
|
68
|
+
timing: { firstTokenMs, totalMs: Date.now() - started },
|
|
69
|
+
model: req.model,
|
|
70
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
71
|
+
} };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw streamError(error, timeout, conn.provider);
|
|
74
|
+
} finally {
|
|
75
|
+
timeout.done();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async listModels(conn: ResolvedConnection): Promise<ModelInfo[]> {
|
|
80
|
+
const { data } = await fetchJson(`${conn.baseUrl}/api/tags`, {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
headers: conn.headers,
|
|
83
|
+
timeoutMs: conn.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
84
|
+
provider: conn.provider,
|
|
85
|
+
});
|
|
86
|
+
const models = Array.isArray(asRecord(data)?.models) ? asRecord(data)!.models as unknown[] : [];
|
|
87
|
+
const ids = models.map((model) => asString(asRecord(model)?.name) ?? asString(asRecord(model)?.model) ?? '').filter(Boolean);
|
|
88
|
+
// Probe /api/show per model for context window + capabilities (best effort, sequential
|
|
89
|
+
// to avoid overwhelming a local Ollama instance).
|
|
90
|
+
const results: ModelInfo[] = [];
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
const info: ModelInfo = { id, source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl } };
|
|
93
|
+
const probed = await this.showModel(conn, id).catch(() => undefined);
|
|
94
|
+
if (probed?.contextWindow !== undefined) info.contextWindow = probed.contextWindow;
|
|
95
|
+
if (probed?.capabilities) info.capabilities = probed.capabilities;
|
|
96
|
+
results.push(info);
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async showModel(conn: ResolvedConnection, model: string): Promise<{ contextWindow?: number; capabilities?: string[] }> {
|
|
102
|
+
const { data } = await fetchJson(`${conn.baseUrl}/api/show`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'content-type': 'application/json', ...conn.headers },
|
|
105
|
+
body: JSON.stringify({ model }),
|
|
106
|
+
timeoutMs: Math.min(conn.timeoutMs ?? DEFAULT_TIMEOUT_MS, 15_000),
|
|
107
|
+
provider: conn.provider,
|
|
108
|
+
});
|
|
109
|
+
const record = asRecord(data);
|
|
110
|
+
const modelInfo = asRecord(record?.model_info);
|
|
111
|
+
let contextWindow: number | undefined;
|
|
112
|
+
if (modelInfo) {
|
|
113
|
+
for (const [key, value] of Object.entries(modelInfo)) {
|
|
114
|
+
if (key.endsWith('.context_length') && typeof value === 'number') { contextWindow = value; break; }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const caps = record?.capabilities;
|
|
118
|
+
const capabilities = Array.isArray(caps) && caps.every((c) => typeof c === 'string') ? caps as string[] : undefined;
|
|
119
|
+
const result: { contextWindow?: number; capabilities?: string[] } = {};
|
|
120
|
+
if (contextWindow !== undefined) result.contextWindow = contextWindow;
|
|
121
|
+
if (capabilities) result.capabilities = capabilities;
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function body(req: ChatRequest): Record<string, unknown> {
|
|
127
|
+
return {
|
|
128
|
+
model: req.model,
|
|
129
|
+
messages: req.messages.map(toOllamaMessage),
|
|
130
|
+
stream: true,
|
|
131
|
+
options: { temperature: req.temperature, num_predict: req.maxTokens, top_p: req.topP, stop: req.stopSequences },
|
|
132
|
+
format: req.responseFormat?.type === 'json' ? (req.responseFormat.schema ?? 'json') : undefined,
|
|
133
|
+
tools: req.tools?.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } })),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toOllamaMessage(m: ChatMessage): Record<string, unknown> {
|
|
138
|
+
const images = typeof m.content === 'string' ? undefined : m.content.filter((part) => part.type === 'image').map((part) => part.imageBase64).filter(Boolean);
|
|
139
|
+
const message: Record<string, unknown> = {
|
|
140
|
+
role: m.role,
|
|
141
|
+
content: typeof m.content === 'string' ? m.content : m.content.filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n'),
|
|
142
|
+
};
|
|
143
|
+
if (images && images.length) message.images = images;
|
|
144
|
+
if (m.role === 'tool' && m.name) message.tool_name = m.name;
|
|
145
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
146
|
+
message.tool_calls = m.toolCalls.map((call) => ({ function: { name: call.name, arguments: call.arguments ?? {} } }));
|
|
147
|
+
}
|
|
148
|
+
return message;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function randomId(): string {
|
|
152
|
+
return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
153
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
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 type { ProviderProfile } from '../profiles.js';
|
|
7
|
+
import { health, listOpenModels, streamError, streamTimeout } from './base.js';
|
|
8
|
+
|
|
9
|
+
export class OpenAIChatAdapter implements ProviderAdapter {
|
|
10
|
+
readonly provider: string;
|
|
11
|
+
constructor(provider: string, private profile: ProviderProfile) {
|
|
12
|
+
this.provider = provider;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async chat(conn: ResolvedConnection, req: ChatRequest): Promise<ChatResult> {
|
|
16
|
+
let final: ChatResult | undefined;
|
|
17
|
+
for await (const event of this.stream(conn, req)) {
|
|
18
|
+
if (event.type === 'done') final = event.result;
|
|
19
|
+
if (event.type === 'error') throw event.error;
|
|
20
|
+
}
|
|
21
|
+
if (!final) throw new AIError('Provider stream ended without a result', { kind: 'invalid_response', provider: conn.provider });
|
|
22
|
+
return final;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async *stream(conn: ResolvedConnection, req: ChatRequest): AsyncIterable<StreamEvent> {
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
let firstTokenMs: number | null = null;
|
|
28
|
+
let text = '';
|
|
29
|
+
let toolCalls: ToolCall[] = [];
|
|
30
|
+
let inputTokens: number | undefined;
|
|
31
|
+
let outputTokens: number | undefined;
|
|
32
|
+
let finish: string | undefined;
|
|
33
|
+
let sawTerminal = false;
|
|
34
|
+
const timeout = streamTimeout(conn, req.signal);
|
|
35
|
+
yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
|
|
36
|
+
try {
|
|
37
|
+
const res = await postResponse(urlFor(conn, this.profile, req.model), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
|
|
38
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
39
|
+
if (!contentType.includes('text/event-stream')) {
|
|
40
|
+
// Server ignored stream:true (or is a buffered gateway) — recover the whole body.
|
|
41
|
+
const rawText = await res.text().catch(() => '');
|
|
42
|
+
const raw = safeParse(rawText) ?? { text: rawText };
|
|
43
|
+
const parsed = parseOpenAiResponse(raw);
|
|
44
|
+
text = parsed.text;
|
|
45
|
+
toolCalls = parsed.toolCalls;
|
|
46
|
+
inputTokens = parsed.inputTokens;
|
|
47
|
+
outputTokens = parsed.outputTokens;
|
|
48
|
+
finish = parsed.finish;
|
|
49
|
+
sawTerminal = true;
|
|
50
|
+
if (text) yield { type: 'delta', text };
|
|
51
|
+
for (const call of toolCalls) yield { type: 'tool_call', call };
|
|
52
|
+
} else {
|
|
53
|
+
const partialTools = new Map<number, { id?: string; name?: string; raw: string }>();
|
|
54
|
+
for await (const line of sseLines(res)) {
|
|
55
|
+
const chunk = safeParse(line);
|
|
56
|
+
const record = asRecord(chunk);
|
|
57
|
+
if (!record) continue;
|
|
58
|
+
const usage = asRecord(record.usage);
|
|
59
|
+
inputTokens = asNumber(usage?.prompt_tokens) ?? inputTokens;
|
|
60
|
+
outputTokens = asNumber(usage?.completion_tokens) ?? outputTokens;
|
|
61
|
+
const choices = Array.isArray(record.choices) ? record.choices : [];
|
|
62
|
+
const choice = asRecord(choices[0]);
|
|
63
|
+
finish = asString(choice?.finish_reason) ?? finish;
|
|
64
|
+
if (choice?.finish_reason) sawTerminal = true;
|
|
65
|
+
const delta = asRecord(choice?.delta);
|
|
66
|
+
const piece = asString(delta?.content);
|
|
67
|
+
if (piece) {
|
|
68
|
+
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
|
69
|
+
text += piece;
|
|
70
|
+
yield { type: 'delta', text: piece };
|
|
71
|
+
}
|
|
72
|
+
const calls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
|
|
73
|
+
for (const callValue of calls) {
|
|
74
|
+
const call = asRecord(callValue);
|
|
75
|
+
const index = asNumber(call?.index) ?? 0;
|
|
76
|
+
const current = partialTools.get(index) ?? { raw: '' };
|
|
77
|
+
current.id = asString(call?.id) ?? current.id;
|
|
78
|
+
const fn = asRecord(call?.function);
|
|
79
|
+
current.name = asString(fn?.name) ?? current.name;
|
|
80
|
+
current.raw += asString(fn?.arguments) ?? '';
|
|
81
|
+
partialTools.set(index, current);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
toolCalls = [...partialTools.values()].filter((call) => call.name).map((call) => ({
|
|
85
|
+
id: call.id ?? randomId(),
|
|
86
|
+
name: call.name!,
|
|
87
|
+
raw: call.raw,
|
|
88
|
+
arguments: parseArgs(call.raw),
|
|
89
|
+
}));
|
|
90
|
+
for (const call of toolCalls) yield { type: 'tool_call', call };
|
|
91
|
+
}
|
|
92
|
+
if (!sawTerminal) {
|
|
93
|
+
yield { type: 'context', kind: 'stream_anomaly', data: { reason: 'stream ended without a finish_reason' } };
|
|
94
|
+
}
|
|
95
|
+
const result = makeResult(conn, req, text, toolCalls, finish, started, firstTokenMs, inputTokens, outputTokens);
|
|
96
|
+
yield { type: 'done', result };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw streamError(error, timeout, conn.provider);
|
|
99
|
+
} finally {
|
|
100
|
+
timeout.done();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
listModels(conn: ResolvedConnection) {
|
|
105
|
+
return listOpenModels(conn, this.profile);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
health(conn: ResolvedConnection) {
|
|
109
|
+
return health(conn, this.profile);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function openAiBody(req: ChatRequest, profile: ProviderProfile): Record<string, unknown> {
|
|
114
|
+
const body: Record<string, unknown> = {
|
|
115
|
+
model: req.model,
|
|
116
|
+
messages: req.messages.map(toOpenAiMessage),
|
|
117
|
+
temperature: req.temperature,
|
|
118
|
+
top_p: req.topP,
|
|
119
|
+
stop: req.stopSequences,
|
|
120
|
+
response_format: responseFormatFor(req, profile),
|
|
121
|
+
tools: req.tools?.map((tool) => ({ type: 'function', function: tool })),
|
|
122
|
+
tool_choice: typeof req.toolChoice === 'object' ? { type: 'function', function: { name: req.toolChoice.name } } : req.toolChoice,
|
|
123
|
+
stream: true,
|
|
124
|
+
// Many OpenAI-compatible local servers (llama.cpp, LM Studio, vLLM) choke
|
|
125
|
+
// on or ignore stream_options — only send it where the profile confirms
|
|
126
|
+
// the server understands it.
|
|
127
|
+
stream_options: profile.quirks?.supportsUsageInStream ? { include_usage: true } : undefined,
|
|
128
|
+
};
|
|
129
|
+
if (req.maxTokens !== undefined) body[profile.quirks?.maxTokensParam ?? 'max_tokens'] = req.maxTokens;
|
|
130
|
+
return body;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function responseFormatFor(req: ChatRequest, profile: ProviderProfile): Record<string, unknown> | undefined {
|
|
134
|
+
if (req.responseFormat?.type !== 'json') return undefined;
|
|
135
|
+
if (req.responseFormat.schema && profile.quirks?.supportsJsonSchema) {
|
|
136
|
+
return { type: 'json_schema', json_schema: { name: 'response', schema: req.responseFormat.schema } };
|
|
137
|
+
}
|
|
138
|
+
return { type: 'json_object' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toOpenAiMessage(message: ChatMessage): Record<string, unknown> {
|
|
142
|
+
return {
|
|
143
|
+
role: message.role,
|
|
144
|
+
content: typeof message.content === 'string'
|
|
145
|
+
? message.content
|
|
146
|
+
: message.content.map((part) => part.type === 'image'
|
|
147
|
+
? { type: 'image_url', image_url: { url: `data:${part.mimeType ?? 'image/png'};base64,${part.imageBase64 ?? ''}` } }
|
|
148
|
+
: { type: 'text', text: part.text ?? '' }),
|
|
149
|
+
name: message.role === 'tool' ? message.name : undefined,
|
|
150
|
+
tool_call_id: message.toolCallId,
|
|
151
|
+
tool_calls: message.toolCalls?.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.raw ?? JSON.stringify(call.arguments) } })),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function urlFor(conn: ResolvedConnection, profile: ProviderProfile, model: string): string {
|
|
156
|
+
if (profile.quirks?.urlTemplate) {
|
|
157
|
+
return profile.quirks.urlTemplate.replace('{baseUrl}', conn.baseUrl).replace('{model}', encodeURIComponent(model));
|
|
158
|
+
}
|
|
159
|
+
return `${conn.baseUrl}/chat/completions`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseOpenAiResponse(raw: unknown): { text: string; toolCalls: ToolCall[]; inputTokens?: number; outputTokens?: number; finish?: string } {
|
|
163
|
+
const record = asRecord(raw);
|
|
164
|
+
const usage = asRecord(record?.usage);
|
|
165
|
+
const choice = asRecord(Array.isArray(record?.choices) ? record.choices[0] : undefined);
|
|
166
|
+
const message = asRecord(choice?.message);
|
|
167
|
+
const text = asString(message?.content) ?? asString(record?.text) ?? '';
|
|
168
|
+
const calls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
|
|
169
|
+
return {
|
|
170
|
+
text,
|
|
171
|
+
finish: asString(choice?.finish_reason),
|
|
172
|
+
toolCalls: calls.map((value) => {
|
|
173
|
+
const call = asRecord(value);
|
|
174
|
+
const fn = asRecord(call?.function);
|
|
175
|
+
const rawArgs = asString(fn?.arguments) ?? '{}';
|
|
176
|
+
return { id: asString(call?.id) ?? randomId(), name: asString(fn?.name) ?? 'unknown', raw: rawArgs, arguments: parseArgs(rawArgs) };
|
|
177
|
+
}),
|
|
178
|
+
inputTokens: asNumber(usage?.prompt_tokens),
|
|
179
|
+
outputTokens: asNumber(usage?.completion_tokens),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function makeResult(conn: ResolvedConnection, req: ChatRequest, text: string, toolCalls: ToolCall[], finish: string | undefined, started: number, firstTokenMs: number | null, inputTokens?: number, outputTokens?: number): ChatResult {
|
|
184
|
+
const usage = inputTokens !== undefined || outputTokens !== undefined
|
|
185
|
+
? { inputTokens, outputTokens, estimated: false }
|
|
186
|
+
: estimatedUsage(textFromMessages(req.messages), text);
|
|
187
|
+
return {
|
|
188
|
+
text,
|
|
189
|
+
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
190
|
+
finishReason: mapFinish(finish, toolCalls.length > 0),
|
|
191
|
+
usage,
|
|
192
|
+
timing: { firstTokenMs, totalMs: Date.now() - started },
|
|
193
|
+
model: req.model,
|
|
194
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function mapFinish(finish: string | undefined, hasToolCalls: boolean): ChatResult['finishReason'] {
|
|
199
|
+
if (hasToolCalls || finish === 'tool_calls' || finish === 'function_call') return 'tool';
|
|
200
|
+
if (finish === 'length') return 'length';
|
|
201
|
+
if (finish === 'content_filter') return 'content_filter';
|
|
202
|
+
return 'stop';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseArgs(raw: string): unknown {
|
|
206
|
+
try {
|
|
207
|
+
return raw ? JSON.parse(raw) as unknown : {};
|
|
208
|
+
} catch {
|
|
209
|
+
return {};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function safeParse(line: string): unknown {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(line) as unknown;
|
|
216
|
+
} catch {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function randomId(): string {
|
|
222
|
+
return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
223
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProviderAdapter } from '../types.js';
|
|
2
|
+
import { AnthropicAdapter } from './engines/anthropic.js';
|
|
3
|
+
import { GoogleAdapter } from './engines/google.js';
|
|
4
|
+
import { OllamaAdapter } from './engines/ollama.js';
|
|
5
|
+
import { OpenAIChatAdapter } from './engines/openaiChat.js';
|
|
6
|
+
import { profileFor } from './profiles.js';
|
|
7
|
+
|
|
8
|
+
export function adapterFor(provider: string, baseUrl?: string): ProviderAdapter {
|
|
9
|
+
const profile = profileFor(provider, baseUrl);
|
|
10
|
+
if (profile.engine === 'anthropic') return new AnthropicAdapter(provider);
|
|
11
|
+
if (profile.engine === 'google') return new GoogleAdapter(provider);
|
|
12
|
+
if (profile.engine === 'ollama') return new OllamaAdapter(provider);
|
|
13
|
+
return new OpenAIChatAdapter(provider, profile);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { PROVIDER_PROFILES, profileFor } from './profiles.js';
|
|
17
|
+
export type { ProviderProfile, EngineName, AuthMode } from './profiles.js';
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type EngineName = 'openaiChat' | 'anthropic' | 'google' | 'ollama';
|
|
2
|
+
export type AuthMode = 'bearer' | 'x-api-key' | 'x-goog-api-key' | 'api-key-header' | 'none';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Answers the capability questions apps need before choosing behavior —
|
|
6
|
+
* distinct from `quirks`, which are wire-format details an adapter consults
|
|
7
|
+
* to build a request. These are per-provider defaults, not per-model: a
|
|
8
|
+
* specific model behind a `true` profile can still lack a capability (a tiny
|
|
9
|
+
* Ollama model ignoring `tools`), and callers that know better should pass an
|
|
10
|
+
* explicit `toolMode` rather than rely on `auto`.
|
|
11
|
+
*/
|
|
12
|
+
export interface ProviderCapabilities {
|
|
13
|
+
/** Native tool/function-calling is reliable for this provider's protocol, not just model-dependent. Drives `toolMode: 'auto'` in the agent loop. */
|
|
14
|
+
nativeTools: boolean;
|
|
15
|
+
/** A provider-enforced structured-output mode exists (not just prompt-and-hope JSON). */
|
|
16
|
+
jsonMode: boolean;
|
|
17
|
+
/** Runs on localhost / user-controlled infrastructure rather than a hosted cloud API. */
|
|
18
|
+
local: boolean;
|
|
19
|
+
/** Meant to run embedded alongside the app (a local sidecar process) rather than accessed as a standalone hosted service. */
|
|
20
|
+
embeddable: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProviderProfile {
|
|
24
|
+
engine: EngineName;
|
|
25
|
+
defaultBaseUrl?: string;
|
|
26
|
+
auth: AuthMode;
|
|
27
|
+
defaultHeaders?: Record<string, string>;
|
|
28
|
+
capabilities: ProviderCapabilities;
|
|
29
|
+
quirks?: {
|
|
30
|
+
keyOptional?: boolean;
|
|
31
|
+
modelOptional?: boolean;
|
|
32
|
+
urlTemplate?: string;
|
|
33
|
+
supportsUsageInStream?: boolean;
|
|
34
|
+
maxTokensRequired?: boolean;
|
|
35
|
+
maxTokensParam?: 'max_tokens' | 'max_completion_tokens';
|
|
36
|
+
supportsJsonSchema?: boolean;
|
|
37
|
+
};
|
|
38
|
+
listModelsPath?: string;
|
|
39
|
+
healthPath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HOSTED_CLOUD: ProviderCapabilities = { nativeTools: true, jsonMode: true, local: false, embeddable: false };
|
|
43
|
+
const LOCAL_RUNTIME: ProviderCapabilities = { nativeTools: false, jsonMode: false, local: true, embeddable: true };
|
|
44
|
+
|
|
45
|
+
export const PROVIDER_PROFILES: Record<string, ProviderProfile> = {
|
|
46
|
+
openai: {
|
|
47
|
+
engine: 'openaiChat',
|
|
48
|
+
defaultBaseUrl: 'https://api.openai.com/v1',
|
|
49
|
+
auth: 'bearer',
|
|
50
|
+
listModelsPath: '/models',
|
|
51
|
+
capabilities: HOSTED_CLOUD,
|
|
52
|
+
quirks: { supportsUsageInStream: true, maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
|
|
53
|
+
},
|
|
54
|
+
'azure-openai': {
|
|
55
|
+
engine: 'openaiChat',
|
|
56
|
+
auth: 'api-key-header',
|
|
57
|
+
capabilities: HOSTED_CLOUD,
|
|
58
|
+
quirks: { urlTemplate: '{baseUrl}/openai/deployments/{model}/chat/completions?api-version=2024-10-21', maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
|
|
59
|
+
},
|
|
60
|
+
openrouter: {
|
|
61
|
+
engine: 'openaiChat',
|
|
62
|
+
defaultBaseUrl: 'https://openrouter.ai/api/v1',
|
|
63
|
+
auth: 'bearer',
|
|
64
|
+
defaultHeaders: { 'HTTP-Referer': 'https://github.com/jxburros/AI-Nugget', 'X-Title': 'ai-nugget' },
|
|
65
|
+
listModelsPath: '/models',
|
|
66
|
+
capabilities: HOSTED_CLOUD,
|
|
67
|
+
quirks: { supportsUsageInStream: true },
|
|
68
|
+
},
|
|
69
|
+
groq: { engine: 'openaiChat', defaultBaseUrl: 'https://api.groq.com/openai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
70
|
+
deepseek: { engine: 'openaiChat', defaultBaseUrl: 'https://api.deepseek.com/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
71
|
+
mistral: { engine: 'openaiChat', defaultBaseUrl: 'https://api.mistral.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
72
|
+
together: { engine: 'openaiChat', defaultBaseUrl: 'https://api.together.xyz/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
73
|
+
fireworks: { engine: 'openaiChat', defaultBaseUrl: 'https://api.fireworks.ai/inference/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
74
|
+
lmstudio: {
|
|
75
|
+
engine: 'openaiChat',
|
|
76
|
+
defaultBaseUrl: 'http://localhost:1234/v1',
|
|
77
|
+
auth: 'none',
|
|
78
|
+
listModelsPath: '/models',
|
|
79
|
+
capabilities: LOCAL_RUNTIME,
|
|
80
|
+
quirks: { keyOptional: true },
|
|
81
|
+
},
|
|
82
|
+
llamacpp: {
|
|
83
|
+
engine: 'openaiChat',
|
|
84
|
+
defaultBaseUrl: 'http://localhost:8080/v1',
|
|
85
|
+
auth: 'none',
|
|
86
|
+
listModelsPath: '/models',
|
|
87
|
+
healthPath: '/health',
|
|
88
|
+
capabilities: LOCAL_RUNTIME,
|
|
89
|
+
quirks: { keyOptional: true, modelOptional: true },
|
|
90
|
+
},
|
|
91
|
+
vllm: {
|
|
92
|
+
engine: 'openaiChat',
|
|
93
|
+
defaultBaseUrl: 'http://localhost:8000/v1',
|
|
94
|
+
auth: 'none',
|
|
95
|
+
listModelsPath: '/models',
|
|
96
|
+
capabilities: LOCAL_RUNTIME,
|
|
97
|
+
quirks: { keyOptional: true },
|
|
98
|
+
},
|
|
99
|
+
ollama: {
|
|
100
|
+
engine: 'ollama',
|
|
101
|
+
defaultBaseUrl: 'http://localhost:11434',
|
|
102
|
+
auth: 'none',
|
|
103
|
+
listModelsPath: '/api/tags',
|
|
104
|
+
// Ollama's `format: 'json'` is engine-enforced (reliable regardless of
|
|
105
|
+
// model); its `tools` field is honored only by models trained for tool
|
|
106
|
+
// use, so nativeTools stays conservative like the other local runtimes.
|
|
107
|
+
capabilities: { ...LOCAL_RUNTIME, jsonMode: true },
|
|
108
|
+
quirks: { keyOptional: true },
|
|
109
|
+
},
|
|
110
|
+
anthropic: {
|
|
111
|
+
engine: 'anthropic',
|
|
112
|
+
defaultBaseUrl: 'https://api.anthropic.com',
|
|
113
|
+
auth: 'x-api-key',
|
|
114
|
+
defaultHeaders: { 'anthropic-version': '2023-06-01' },
|
|
115
|
+
capabilities: HOSTED_CLOUD,
|
|
116
|
+
quirks: { maxTokensRequired: true },
|
|
117
|
+
},
|
|
118
|
+
google: {
|
|
119
|
+
engine: 'google',
|
|
120
|
+
defaultBaseUrl: 'https://generativelanguage.googleapis.com',
|
|
121
|
+
auth: 'x-goog-api-key',
|
|
122
|
+
capabilities: HOSTED_CLOUD,
|
|
123
|
+
},
|
|
124
|
+
'openai-compat': {
|
|
125
|
+
engine: 'openaiChat',
|
|
126
|
+
auth: 'bearer',
|
|
127
|
+
listModelsPath: '/models',
|
|
128
|
+
// Unknown endpoint — usually pointed at a local/self-hosted server, so
|
|
129
|
+
// assume the conservative local-runtime defaults rather than the hosted-cloud ones.
|
|
130
|
+
capabilities: LOCAL_RUNTIME,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function profileFor(provider: string, baseUrl?: string): ProviderProfile {
|
|
135
|
+
return PROVIDER_PROFILES[provider] ?? {
|
|
136
|
+
...PROVIDER_PROFILES['openai-compat']!,
|
|
137
|
+
defaultBaseUrl: baseUrl,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function applyAuth(profile: ProviderProfile, apiKey: string | null, headers: Record<string, string>): Record<string, string> {
|
|
142
|
+
const next = { ...profile.defaultHeaders, ...headers };
|
|
143
|
+
if (!apiKey) return next;
|
|
144
|
+
if (profile.auth === 'bearer') next.authorization = `Bearer ${apiKey}`;
|
|
145
|
+
else if (profile.auth === 'x-api-key') next['x-api-key'] = apiKey;
|
|
146
|
+
else if (profile.auth === 'x-goog-api-key') next['x-goog-api-key'] = apiKey;
|
|
147
|
+
else if (profile.auth === 'api-key-header') next['api-key'] = apiKey;
|
|
148
|
+
return next;
|
|
149
|
+
}
|