@jxburros/ai-nugget 0.4.1 → 0.7.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 +158 -382
- package/UPGRADING.md +182 -0
- package/dist/adapters/engines/anthropic.d.ts +7 -1
- package/dist/adapters/engines/anthropic.js +82 -37
- package/dist/adapters/engines/base.d.ts +29 -2
- package/dist/adapters/engines/base.js +52 -8
- package/dist/adapters/engines/google.d.ts +7 -1
- package/dist/adapters/engines/google.js +80 -29
- package/dist/adapters/engines/ollama.d.ts +2 -1
- package/dist/adapters/engines/ollama.js +57 -15
- package/dist/adapters/engines/openaiChat.d.ts +8 -1
- package/dist/adapters/engines/openaiChat.js +109 -29
- package/dist/adapters/profiles.d.ts +21 -1
- package/dist/adapters/profiles.js +12 -2
- package/dist/agent/loop.d.ts +45 -1
- package/dist/agent/loop.js +117 -12
- package/dist/cjs/adapters/engines/anthropic.js +326 -0
- package/dist/cjs/adapters/engines/base.js +147 -0
- package/dist/cjs/adapters/engines/google.js +291 -0
- package/dist/cjs/adapters/engines/ollama.js +205 -0
- package/dist/cjs/adapters/engines/openaiChat.js +312 -0
- package/dist/cjs/adapters/index.js +22 -0
- package/dist/cjs/adapters/profiles.js +125 -0
- package/dist/cjs/agent/index.js +18 -0
- package/dist/cjs/agent/loop.js +373 -0
- package/dist/cjs/agent/tools.js +51 -0
- package/dist/cjs/connect.js +27 -0
- package/dist/cjs/errors.js +110 -0
- package/dist/cjs/handler.js +586 -0
- package/dist/cjs/index.js +28 -0
- package/dist/cjs/json.js +121 -0
- package/dist/cjs/keys.js +81 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/policy.js +42 -0
- package/dist/cjs/reasoning.js +159 -0
- package/dist/cjs/redact.js +78 -0
- package/dist/cjs/tokens.js +22 -0
- package/dist/cjs/transport.js +172 -0
- package/dist/cjs/types.js +6 -0
- package/dist/cjs/util.js +126 -0
- package/dist/errors.js +25 -1
- package/dist/handler.d.ts +67 -1
- package/dist/handler.js +266 -61
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/reasoning.d.ts +45 -0
- package/dist/reasoning.js +154 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +29 -1
- package/dist/transport.d.ts +3 -1
- package/dist/transport.js +21 -1
- package/dist/types.d.ts +112 -2
- package/dist/util.d.ts +22 -0
- package/dist/util.js +59 -0
- package/nugget/VERSION.txt +2 -2
- package/nugget/dist/adapters/engines/anthropic.d.ts +14 -0
- package/nugget/dist/adapters/engines/anthropic.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/anthropic.js +323 -0
- package/nugget/dist/adapters/engines/anthropic.js.map +1 -0
- package/nugget/dist/adapters/engines/base.d.ts +50 -0
- package/nugget/dist/adapters/engines/base.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/base.js +137 -0
- package/nugget/dist/adapters/engines/base.js.map +1 -0
- package/nugget/dist/adapters/engines/google.d.ts +24 -0
- package/nugget/dist/adapters/engines/google.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/google.js +288 -0
- package/nugget/dist/adapters/engines/google.js.map +1 -0
- package/nugget/dist/adapters/engines/ollama.d.ts +11 -0
- package/nugget/dist/adapters/engines/ollama.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/ollama.js +202 -0
- package/nugget/dist/adapters/engines/ollama.js.map +1 -0
- package/nugget/dist/adapters/engines/openaiChat.d.ts +22 -0
- package/nugget/dist/adapters/engines/openaiChat.d.ts.map +1 -0
- package/nugget/dist/adapters/engines/openaiChat.js +308 -0
- package/nugget/dist/adapters/engines/openaiChat.js.map +1 -0
- package/nugget/dist/adapters/index.d.ts +5 -0
- package/nugget/dist/adapters/index.d.ts.map +1 -0
- package/nugget/dist/adapters/index.js +17 -0
- package/nugget/dist/adapters/index.js.map +1 -0
- package/nugget/dist/adapters/profiles.d.ts +62 -0
- package/nugget/dist/adapters/profiles.d.ts.map +1 -0
- package/nugget/dist/adapters/profiles.js +121 -0
- package/nugget/dist/adapters/profiles.js.map +1 -0
- package/nugget/dist/agent/index.d.ts +3 -0
- package/nugget/dist/agent/index.d.ts.map +1 -0
- package/nugget/dist/agent/index.js +3 -0
- package/nugget/dist/agent/index.js.map +1 -0
- package/nugget/dist/agent/loop.d.ts +114 -0
- package/nugget/dist/agent/loop.d.ts.map +1 -0
- package/nugget/dist/agent/loop.js +371 -0
- package/nugget/dist/agent/loop.js.map +1 -0
- package/nugget/dist/agent/tools.d.ts +29 -0
- package/nugget/dist/agent/tools.d.ts.map +1 -0
- package/nugget/dist/agent/tools.js +48 -0
- package/nugget/dist/agent/tools.js.map +1 -0
- package/nugget/dist/cjs/adapters/engines/anthropic.js +326 -0
- package/nugget/dist/cjs/adapters/engines/base.js +147 -0
- package/nugget/dist/cjs/adapters/engines/google.js +291 -0
- package/nugget/dist/cjs/adapters/engines/ollama.js +205 -0
- package/nugget/dist/cjs/adapters/engines/openaiChat.js +312 -0
- package/nugget/dist/cjs/adapters/index.js +22 -0
- package/nugget/dist/cjs/adapters/profiles.js +125 -0
- package/nugget/dist/cjs/agent/index.js +18 -0
- package/nugget/dist/cjs/agent/loop.js +373 -0
- package/nugget/dist/cjs/agent/tools.js +51 -0
- package/nugget/dist/cjs/connect.js +27 -0
- package/nugget/dist/cjs/errors.js +110 -0
- package/nugget/dist/cjs/handler.js +586 -0
- package/nugget/dist/cjs/index.js +28 -0
- package/nugget/dist/cjs/json.js +121 -0
- package/nugget/dist/cjs/keys.js +81 -0
- package/nugget/dist/cjs/package.json +3 -0
- package/nugget/dist/cjs/policy.js +42 -0
- package/nugget/dist/cjs/reasoning.js +159 -0
- package/nugget/dist/cjs/redact.js +78 -0
- package/nugget/dist/cjs/tokens.js +22 -0
- package/nugget/dist/cjs/transport.js +172 -0
- package/nugget/dist/cjs/types.js +6 -0
- package/nugget/dist/cjs/util.js +126 -0
- package/nugget/dist/connect.d.ts +31 -0
- package/nugget/dist/connect.d.ts.map +1 -0
- package/nugget/dist/connect.js +25 -0
- package/nugget/dist/connect.js.map +1 -0
- package/nugget/dist/errors.d.ts +22 -0
- package/nugget/dist/errors.d.ts.map +1 -0
- package/nugget/dist/errors.js +104 -0
- package/nugget/dist/errors.js.map +1 -0
- package/nugget/dist/handler.d.ts +114 -0
- package/nugget/dist/handler.d.ts.map +1 -0
- package/nugget/dist/handler.js +582 -0
- package/nugget/dist/handler.js.map +1 -0
- package/nugget/dist/index.d.ts +13 -0
- package/nugget/dist/index.d.ts.map +1 -0
- package/nugget/dist/index.js +13 -0
- package/nugget/dist/index.js.map +1 -0
- package/nugget/dist/json.d.ts +7 -0
- package/nugget/dist/json.d.ts.map +1 -0
- package/nugget/dist/json.js +114 -0
- package/nugget/dist/json.js.map +1 -0
- package/nugget/dist/keys.d.ts +7 -0
- package/nugget/dist/keys.d.ts.map +1 -0
- package/nugget/dist/keys.js +75 -0
- package/nugget/dist/keys.js.map +1 -0
- package/nugget/dist/policy.d.ts +6 -0
- package/nugget/dist/policy.d.ts.map +1 -0
- package/nugget/dist/policy.js +37 -0
- package/nugget/dist/policy.js.map +1 -0
- package/nugget/dist/reasoning.d.ts +45 -0
- package/nugget/dist/reasoning.d.ts.map +1 -0
- package/nugget/dist/reasoning.js +154 -0
- package/nugget/dist/reasoning.js.map +1 -0
- package/nugget/dist/redact.d.ts +26 -0
- package/nugget/dist/redact.d.ts.map +1 -0
- package/nugget/dist/redact.js +74 -0
- package/nugget/dist/redact.js.map +1 -0
- package/nugget/dist/tokens.d.ts +5 -0
- package/nugget/dist/tokens.d.ts.map +1 -0
- package/nugget/dist/tokens.js +18 -0
- package/nugget/dist/tokens.js.map +1 -0
- package/nugget/dist/transport.d.ts +30 -0
- package/nugget/dist/transport.d.ts.map +1 -0
- package/nugget/dist/transport.js +165 -0
- package/nugget/dist/transport.js.map +1 -0
- package/nugget/dist/types.d.ts +301 -0
- package/nugget/dist/types.d.ts.map +1 -0
- package/nugget/dist/types.js +4 -0
- package/nugget/dist/types.js.map +1 -0
- package/nugget/dist/util.d.ts +31 -0
- package/nugget/dist/util.d.ts.map +1 -0
- package/nugget/dist/util.js +114 -0
- package/nugget/dist/util.js.map +1 -0
- package/nugget/src/adapters/engines/anthropic.ts +78 -37
- package/nugget/src/adapters/engines/base.ts +50 -7
- package/nugget/src/adapters/engines/google.ts +79 -29
- package/nugget/src/adapters/engines/ollama.ts +56 -17
- package/nugget/src/adapters/engines/openaiChat.ts +104 -30
- package/nugget/src/adapters/profiles.ts +53 -3
- package/nugget/src/agent/loop.ts +156 -13
- package/nugget/src/errors.ts +25 -1
- package/nugget/src/handler.ts +308 -59
- package/nugget/src/index.ts +1 -0
- package/nugget/src/reasoning.ts +174 -0
- package/nugget/src/redact.ts +32 -1
- package/nugget/src/transport.ts +20 -1
- package/nugget/src/types.ts +113 -1
- package/nugget/src/util.ts +64 -0
- package/package.json +18 -5
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { AIError } from '../../errors.js';
|
|
2
2
|
import { estimatedUsage } from '../../tokens.js';
|
|
3
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';
|
|
4
|
+
import type { ChatMessage, ChatRequest, ChatResult, EmbedRequest, EmbedResult, ModelInfo, ProviderAdapter, ResolvedConnection, StreamEvent, ToolCall } from '../../types.js';
|
|
5
|
+
import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, mapWithConcurrency, textFromMessages } from '../../util.js';
|
|
6
|
+
import { DEFAULT_TIMEOUT_MS, parseArgs, randomId, streamAnomaly, streamError, streamTimeout } from './base.js';
|
|
7
|
+
|
|
8
|
+
/** Max concurrent /api/show probes when listing models — enough to be fast, few enough to be polite to a local daemon. */
|
|
9
|
+
const SHOW_CONCURRENCY = 6;
|
|
7
10
|
|
|
8
11
|
export class OllamaAdapter implements ProviderAdapter {
|
|
9
12
|
readonly provider: string;
|
|
@@ -28,14 +31,18 @@ export class OllamaAdapter implements ProviderAdapter {
|
|
|
28
31
|
let inputTokens: number | undefined;
|
|
29
32
|
let outputTokens: number | undefined;
|
|
30
33
|
let doneReason: string | undefined;
|
|
34
|
+
let sawTerminal = false;
|
|
31
35
|
const toolCalls: ToolCall[] = [];
|
|
32
36
|
const timeout = streamTimeout(conn, req.signal);
|
|
33
37
|
yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
|
|
34
38
|
try {
|
|
35
|
-
const res = await postResponse(
|
|
39
|
+
const res = await postResponse(joinUrl(conn.baseUrl, '/api/chat'), body(req), conn.headers, timeout.signal, conn.provider);
|
|
36
40
|
for await (const value of ndjsonLines(res)) {
|
|
41
|
+
timeout.bump();
|
|
37
42
|
const record = asRecord(value);
|
|
38
43
|
const message = asRecord(record?.message);
|
|
44
|
+
const thinking = asString(message?.thinking);
|
|
45
|
+
if (thinking) yield { type: 'reasoning', text: thinking };
|
|
39
46
|
const piece = asString(message?.content);
|
|
40
47
|
if (piece) {
|
|
41
48
|
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
|
@@ -45,6 +52,7 @@ export class OllamaAdapter implements ProviderAdapter {
|
|
|
45
52
|
inputTokens = asNumber(record?.prompt_eval_count) ?? inputTokens;
|
|
46
53
|
outputTokens = asNumber(record?.eval_count) ?? outputTokens;
|
|
47
54
|
doneReason = asString(record?.done_reason) ?? doneReason;
|
|
55
|
+
if (record?.done === true) sawTerminal = true;
|
|
48
56
|
const calls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
|
|
49
57
|
for (const callValue of calls) {
|
|
50
58
|
const fn = asRecord(asRecord(callValue)?.function);
|
|
@@ -52,13 +60,17 @@ export class OllamaAdapter implements ProviderAdapter {
|
|
|
52
60
|
const toolCall: ToolCall = {
|
|
53
61
|
id: randomId(),
|
|
54
62
|
name: asString(fn?.name) ?? 'unknown',
|
|
55
|
-
|
|
63
|
+
// Ollama itself sends a native object, but llama.cpp-style backends
|
|
64
|
+
// behind the same protocol send a JSON *string* — coerce either into
|
|
65
|
+
// the object `validateToolArgs` expects.
|
|
66
|
+
arguments: parseArgs(args),
|
|
56
67
|
raw: typeof args === 'string' ? args : JSON.stringify(args),
|
|
57
68
|
};
|
|
58
69
|
toolCalls.push(toolCall);
|
|
59
70
|
yield { type: 'tool_call', call: toolCall };
|
|
60
71
|
}
|
|
61
72
|
}
|
|
73
|
+
if (!sawTerminal) yield streamAnomaly('NDJSON stream ended without a done record');
|
|
62
74
|
const hasTools = toolCalls.length > 0;
|
|
63
75
|
yield { type: 'done', result: {
|
|
64
76
|
text,
|
|
@@ -85,17 +97,38 @@ export class OllamaAdapter implements ProviderAdapter {
|
|
|
85
97
|
});
|
|
86
98
|
const models = Array.isArray(asRecord(data)?.models) ? asRecord(data)!.models as unknown[] : [];
|
|
87
99
|
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,
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
// Probe /api/show per model for context window + capabilities (best effort),
|
|
101
|
+
// with bounded concurrency so a large model list resolves quickly without
|
|
102
|
+
// opening an unbounded number of sockets against a local daemon.
|
|
103
|
+
return mapWithConcurrency(ids, SHOW_CONCURRENCY, async (id) => {
|
|
92
104
|
const info: ModelInfo = { id, source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl } };
|
|
93
105
|
const probed = await this.showModel(conn, id).catch(() => undefined);
|
|
94
106
|
if (probed?.contextWindow !== undefined) info.contextWindow = probed.contextWindow;
|
|
95
107
|
if (probed?.capabilities) info.capabilities = probed.capabilities;
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
108
|
+
return info;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async embed(conn: ResolvedConnection, req: EmbedRequest): Promise<EmbedResult> {
|
|
113
|
+
const inputs = Array.isArray(req.input) ? req.input : [req.input];
|
|
114
|
+
const { data } = await fetchJson(joinUrl(conn.baseUrl, '/api/embed'), {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'content-type': 'application/json', ...conn.headers },
|
|
117
|
+
body: JSON.stringify(applyProviderOptions({ model: req.model, input: inputs }, req.providerOptions)),
|
|
118
|
+
timeoutMs: conn.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
119
|
+
provider: conn.provider,
|
|
120
|
+
});
|
|
121
|
+
const record = asRecord(data);
|
|
122
|
+
const rows = Array.isArray(record?.embeddings) ? record.embeddings : [];
|
|
123
|
+
const embeddings = rows.map((row) => (Array.isArray(row) ? row.filter((n): n is number => typeof n === 'number') : []));
|
|
124
|
+
const inputTokens = asNumber(record?.prompt_eval_count);
|
|
125
|
+
return {
|
|
126
|
+
embeddings,
|
|
127
|
+
model: req.model,
|
|
128
|
+
usage: { inputTokens, outputTokens: 0, estimated: inputTokens === undefined },
|
|
129
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
130
|
+
raw: data,
|
|
131
|
+
};
|
|
99
132
|
}
|
|
100
133
|
|
|
101
134
|
private async showModel(conn: ResolvedConnection, model: string): Promise<{ contextWindow?: number; capabilities?: string[] }> {
|
|
@@ -124,7 +157,7 @@ export class OllamaAdapter implements ProviderAdapter {
|
|
|
124
157
|
}
|
|
125
158
|
|
|
126
159
|
function body(req: ChatRequest): Record<string, unknown> {
|
|
127
|
-
|
|
160
|
+
const base: Record<string, unknown> = {
|
|
128
161
|
model: req.model,
|
|
129
162
|
messages: req.messages.map(toOllamaMessage),
|
|
130
163
|
stream: true,
|
|
@@ -132,6 +165,16 @@ function body(req: ChatRequest): Record<string, unknown> {
|
|
|
132
165
|
format: req.responseFormat?.type === 'json' ? (req.responseFormat.schema ?? 'json') : undefined,
|
|
133
166
|
tools: req.tools?.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters } })),
|
|
134
167
|
};
|
|
168
|
+
// First-class reasoning effort → Ollama's `think` flag (reasoning arrives in
|
|
169
|
+
// `message.thinking`, which this engine already routes to `reasoning` events).
|
|
170
|
+
// Ollama has no graded effort for most models, so anything but `'none'` is
|
|
171
|
+
// `true`. Only sent when asked for: a model without thinking support rejects it.
|
|
172
|
+
if (req.reasoningEffort !== undefined) base.think = req.reasoningEffort !== 'none';
|
|
173
|
+
// providerOptions reaches Ollama's real request fields — `options.num_ctx`,
|
|
174
|
+
// `options.num_keep`, top-level `keep_alive`, `think`, etc. `options` is
|
|
175
|
+
// merged one level deep so num_ctx joins the samplers above rather than
|
|
176
|
+
// replacing them.
|
|
177
|
+
return applyProviderOptions(base, req.providerOptions, ['options']);
|
|
135
178
|
}
|
|
136
179
|
|
|
137
180
|
function toOllamaMessage(m: ChatMessage): Record<string, unknown> {
|
|
@@ -147,7 +190,3 @@ function toOllamaMessage(m: ChatMessage): Record<string, unknown> {
|
|
|
147
190
|
}
|
|
148
191
|
return message;
|
|
149
192
|
}
|
|
150
|
-
|
|
151
|
-
function randomId(): string {
|
|
152
|
-
return globalThis.crypto?.randomUUID?.() ?? `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
153
|
-
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { AIError } from '../../errors.js';
|
|
2
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';
|
|
3
|
+
import { fetchJson, postResponse, sseLines } from '../../transport.js';
|
|
4
|
+
import type { ChatMessage, ChatRequest, ChatResult, EmbedRequest, EmbedResult, ProviderAdapter, ResolvedConnection, StreamEvent, ToolCall } from '../../types.js';
|
|
5
|
+
import { applyProviderOptions, asNumber, asRecord, asString, joinUrl, textFromMessages } from '../../util.js';
|
|
6
6
|
import type { ProviderProfile } from '../profiles.js';
|
|
7
|
-
import { health, listOpenModels, streamError, streamTimeout } from './base.js';
|
|
7
|
+
import { DEFAULT_TIMEOUT_MS, health, listOpenModels, parseArgs, randomId, safeParse, streamAnomaly, streamError, streamTimeout } from './base.js';
|
|
8
8
|
|
|
9
9
|
export class OpenAIChatAdapter implements ProviderAdapter {
|
|
10
10
|
readonly provider: string;
|
|
@@ -34,7 +34,32 @@ export class OpenAIChatAdapter implements ProviderAdapter {
|
|
|
34
34
|
const timeout = streamTimeout(conn, req.signal);
|
|
35
35
|
yield { type: 'start', callId: '', provider: conn.provider, model: req.model };
|
|
36
36
|
try {
|
|
37
|
-
|
|
37
|
+
let res: Response;
|
|
38
|
+
try {
|
|
39
|
+
res = await postResponse(urlFor(conn, this.profile, req), openAiBody(req, this.profile), conn.headers, timeout.signal, conn.provider);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// OpenAI's `/chat/completions` refuses function tools on a reasoning model
|
|
42
|
+
// unless `reasoning_effort` is `'none'` ("Function tools with reasoning_effort
|
|
43
|
+
// are not supported … use /v1/responses or set reasoning_effort to 'none'").
|
|
44
|
+
// The nugget never sets `reasoning_effort` itself, so the provider default
|
|
45
|
+
// applies and every tool-using turn 400s. Rather than make each app learn
|
|
46
|
+
// this the hard way, retry the one request once with the effort disabled
|
|
47
|
+
// and say so on the stream. Reactive on purpose: sending
|
|
48
|
+
// `reasoning_effort` up front would 400 on non-reasoning models instead.
|
|
49
|
+
// A caller-requested effort is overridden too — the alternative is a hard
|
|
50
|
+
// 400 with no tool call at all — and the context event says so.
|
|
51
|
+
if (!shouldRetryWithoutReasoningEffort(error, req)) throw error;
|
|
52
|
+
yield {
|
|
53
|
+
type: 'context',
|
|
54
|
+
kind: 'reasoning_effort_disabled_for_tools',
|
|
55
|
+
data: {
|
|
56
|
+
reason: 'provider rejected function tools with reasoning_effort; retried with reasoning_effort: none',
|
|
57
|
+
requested: req.providerOptions?.reasoning_effort ?? req.reasoningEffort ?? null,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const retryReq: ChatRequest = { ...req, providerOptions: { ...req.providerOptions, reasoning_effort: 'none' } };
|
|
61
|
+
res = await postResponse(urlFor(conn, this.profile, retryReq), openAiBody(retryReq, this.profile), conn.headers, timeout.signal, conn.provider);
|
|
62
|
+
}
|
|
38
63
|
const contentType = res.headers.get('content-type') ?? '';
|
|
39
64
|
if (!contentType.includes('text/event-stream')) {
|
|
40
65
|
// Server ignored stream:true (or is a buffered gateway) — recover the whole body.
|
|
@@ -47,11 +72,13 @@ export class OpenAIChatAdapter implements ProviderAdapter {
|
|
|
47
72
|
outputTokens = parsed.outputTokens;
|
|
48
73
|
finish = parsed.finish;
|
|
49
74
|
sawTerminal = true;
|
|
75
|
+
if (parsed.reasoning) yield { type: 'reasoning', text: parsed.reasoning };
|
|
50
76
|
if (text) yield { type: 'delta', text };
|
|
51
77
|
for (const call of toolCalls) yield { type: 'tool_call', call };
|
|
52
78
|
} else {
|
|
53
79
|
const partialTools = new Map<number, { id?: string; name?: string; raw: string }>();
|
|
54
80
|
for await (const line of sseLines(res)) {
|
|
81
|
+
timeout.bump();
|
|
55
82
|
const chunk = safeParse(line);
|
|
56
83
|
const record = asRecord(chunk);
|
|
57
84
|
if (!record) continue;
|
|
@@ -63,6 +90,10 @@ export class OpenAIChatAdapter implements ProviderAdapter {
|
|
|
63
90
|
finish = asString(choice?.finish_reason) ?? finish;
|
|
64
91
|
if (choice?.finish_reason) sawTerminal = true;
|
|
65
92
|
const delta = asRecord(choice?.delta);
|
|
93
|
+
// Reasoning tokens (DeepSeek `reasoning_content`, others `reasoning`)
|
|
94
|
+
// ride their own channel so they don't contaminate the answer text.
|
|
95
|
+
const reasoning = asString(delta?.reasoning_content) ?? asString(delta?.reasoning);
|
|
96
|
+
if (reasoning) yield { type: 'reasoning', text: reasoning };
|
|
66
97
|
const piece = asString(delta?.content);
|
|
67
98
|
if (piece) {
|
|
68
99
|
if (firstTokenMs === null) firstTokenMs = Date.now() - started;
|
|
@@ -89,9 +120,7 @@ export class OpenAIChatAdapter implements ProviderAdapter {
|
|
|
89
120
|
}));
|
|
90
121
|
for (const call of toolCalls) yield { type: 'tool_call', call };
|
|
91
122
|
}
|
|
92
|
-
if (!sawTerminal)
|
|
93
|
-
yield { type: 'context', kind: 'stream_anomaly', data: { reason: 'stream ended without a finish_reason' } };
|
|
94
|
-
}
|
|
123
|
+
if (!sawTerminal) yield streamAnomaly('stream ended without a finish_reason');
|
|
95
124
|
const result = makeResult(conn, req, text, toolCalls, finish, started, firstTokenMs, inputTokens, outputTokens);
|
|
96
125
|
yield { type: 'done', result };
|
|
97
126
|
} catch (error) {
|
|
@@ -108,6 +137,36 @@ export class OpenAIChatAdapter implements ProviderAdapter {
|
|
|
108
137
|
health(conn: ResolvedConnection) {
|
|
109
138
|
return health(conn, this.profile);
|
|
110
139
|
}
|
|
140
|
+
|
|
141
|
+
async embed(conn: ResolvedConnection, req: EmbedRequest): Promise<EmbedResult> {
|
|
142
|
+
const inputs = Array.isArray(req.input) ? req.input : [req.input];
|
|
143
|
+
const { data } = await fetchJson(joinUrl(conn.baseUrl, '/embeddings'), {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'content-type': 'application/json', ...conn.headers },
|
|
146
|
+
body: JSON.stringify(applyProviderOptions({ model: req.model, input: inputs }, req.providerOptions)),
|
|
147
|
+
timeoutMs: conn.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
148
|
+
provider: conn.provider,
|
|
149
|
+
});
|
|
150
|
+
const record = asRecord(data);
|
|
151
|
+
const rows = Array.isArray(record?.data) ? record.data : [];
|
|
152
|
+
// Order by the `index` each row reports, falling back to array order.
|
|
153
|
+
const byIndex = rows
|
|
154
|
+
.map((row, i) => ({ index: asNumber(asRecord(row)?.index) ?? i, embedding: asEmbedding(asRecord(row)?.embedding) }))
|
|
155
|
+
.sort((a, b) => a.index - b.index);
|
|
156
|
+
const usage = asRecord(record?.usage);
|
|
157
|
+
const inputTokens = asNumber(usage?.prompt_tokens);
|
|
158
|
+
return {
|
|
159
|
+
embeddings: byIndex.map((row) => row.embedding),
|
|
160
|
+
model: req.model,
|
|
161
|
+
usage: { inputTokens, outputTokens: 0, estimated: inputTokens === undefined },
|
|
162
|
+
source: { provider: conn.provider, connectionId: conn.id, baseUrl: conn.baseUrl },
|
|
163
|
+
raw: data,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function asEmbedding(value: unknown): number[] {
|
|
169
|
+
return Array.isArray(value) ? value.filter((n): n is number => typeof n === 'number') : [];
|
|
111
170
|
}
|
|
112
171
|
|
|
113
172
|
function openAiBody(req: ChatRequest, profile: ProviderProfile): Record<string, unknown> {
|
|
@@ -127,7 +186,15 @@ function openAiBody(req: ChatRequest, profile: ProviderProfile): Record<string,
|
|
|
127
186
|
stream_options: profile.quirks?.supportsUsageInStream ? { include_usage: true } : undefined,
|
|
128
187
|
};
|
|
129
188
|
if (req.maxTokens !== undefined) body[profile.quirks?.maxTokensParam ?? 'max_tokens'] = req.maxTokens;
|
|
130
|
-
|
|
189
|
+
// First-class reasoning effort → OpenAI's `reasoning_effort`. Only sent when
|
|
190
|
+
// asked for: non-reasoning models reject the parameter outright.
|
|
191
|
+
if (req.reasoningEffort !== undefined) body.reasoning_effort = req.reasoningEffort;
|
|
192
|
+
// providerOptions carries OpenAI-native fields the nugget doesn't model
|
|
193
|
+
// (`reasoning_effort`, `parallel_tool_calls`, `seed`, `logprobs`, …). `apiVersion`
|
|
194
|
+
// is consumed by urlFor for Azure and stripped here so it never hits the body.
|
|
195
|
+
const { apiVersion, ...passthrough } = req.providerOptions ?? {};
|
|
196
|
+
void apiVersion;
|
|
197
|
+
return applyProviderOptions(body, Object.keys(passthrough).length ? passthrough : undefined);
|
|
131
198
|
}
|
|
132
199
|
|
|
133
200
|
function responseFormatFor(req: ChatRequest, profile: ProviderProfile): Record<string, unknown> | undefined {
|
|
@@ -152,22 +219,31 @@ function toOpenAiMessage(message: ChatMessage): Record<string, unknown> {
|
|
|
152
219
|
};
|
|
153
220
|
}
|
|
154
221
|
|
|
155
|
-
function urlFor(conn: ResolvedConnection, profile: ProviderProfile,
|
|
222
|
+
function urlFor(conn: ResolvedConnection, profile: ProviderProfile, req: ChatRequest): string {
|
|
156
223
|
if (profile.quirks?.urlTemplate) {
|
|
157
|
-
|
|
224
|
+
// `{apiVersion}` (Azure) is filled from a per-call override, then the
|
|
225
|
+
// profile default — so a retired Azure api-version is fixable without a
|
|
226
|
+
// library release: pass `providerOptions: { apiVersion: '2025-01-01-preview' }`.
|
|
227
|
+
const apiVersion = asString(req.providerOptions?.apiVersion) ?? profile.quirks.apiVersion ?? '';
|
|
228
|
+
return profile.quirks.urlTemplate
|
|
229
|
+
.replace('{baseUrl}', conn.baseUrl)
|
|
230
|
+
.replace('{model}', encodeURIComponent(req.model))
|
|
231
|
+
.replace('{apiVersion}', apiVersion);
|
|
158
232
|
}
|
|
159
|
-
return
|
|
233
|
+
return joinUrl(conn.baseUrl, '/chat/completions');
|
|
160
234
|
}
|
|
161
235
|
|
|
162
|
-
function parseOpenAiResponse(raw: unknown): { text: string; toolCalls: ToolCall[]; inputTokens?: number; outputTokens?: number; finish?: string } {
|
|
236
|
+
function parseOpenAiResponse(raw: unknown): { text: string; reasoning?: string; toolCalls: ToolCall[]; inputTokens?: number; outputTokens?: number; finish?: string } {
|
|
163
237
|
const record = asRecord(raw);
|
|
164
238
|
const usage = asRecord(record?.usage);
|
|
165
239
|
const choice = asRecord(Array.isArray(record?.choices) ? record.choices[0] : undefined);
|
|
166
240
|
const message = asRecord(choice?.message);
|
|
167
241
|
const text = asString(message?.content) ?? asString(record?.text) ?? '';
|
|
242
|
+
const reasoning = asString(message?.reasoning_content) ?? asString(message?.reasoning);
|
|
168
243
|
const calls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
|
|
169
244
|
return {
|
|
170
245
|
text,
|
|
246
|
+
reasoning,
|
|
171
247
|
finish: asString(choice?.finish_reason),
|
|
172
248
|
toolCalls: calls.map((value) => {
|
|
173
249
|
const call = asRecord(value);
|
|
@@ -202,22 +278,20 @@ function mapFinish(finish: string | undefined, hasToolCalls: boolean): ChatResul
|
|
|
202
278
|
return 'stop';
|
|
203
279
|
}
|
|
204
280
|
|
|
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
281
|
|
|
221
|
-
|
|
222
|
-
|
|
282
|
+
/**
|
|
283
|
+
* True when a `/chat/completions` 400 is the "function tools with reasoning_effort
|
|
284
|
+
* are not supported" refusal AND the request actually carried tools AND the caller
|
|
285
|
+
* did not already pin `reasoning_effort` (in which case the choice is theirs).
|
|
286
|
+
*/
|
|
287
|
+
export function shouldRetryWithoutReasoningEffort(error: unknown, req: ChatRequest): boolean {
|
|
288
|
+
if (!(error instanceof AIError) || error.status !== 400) return false;
|
|
289
|
+
if (!req.tools || req.tools.length === 0) return false;
|
|
290
|
+
// Already `'none'` (from either knob) — the refusal is about something else.
|
|
291
|
+
const effective = req.providerOptions && 'reasoning_effort' in req.providerOptions
|
|
292
|
+
? req.providerOptions.reasoning_effort
|
|
293
|
+
: req.reasoningEffort;
|
|
294
|
+
if (effective === 'none') return false;
|
|
295
|
+
const text = `${error.raw ?? ''} ${error.message}`.toLowerCase();
|
|
296
|
+
return text.includes('reasoning_effort') && text.includes('tool');
|
|
223
297
|
}
|
|
@@ -34,6 +34,20 @@ export interface ProviderProfile {
|
|
|
34
34
|
maxTokensRequired?: boolean;
|
|
35
35
|
maxTokensParam?: 'max_tokens' | 'max_completion_tokens';
|
|
36
36
|
supportsJsonSchema?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Default value for the `{apiVersion}` token in `urlTemplate` (Azure OpenAI).
|
|
39
|
+
* Overridable per call via `ChatRequest.providerOptions.apiVersion`, so a
|
|
40
|
+
* retired Azure api-version never requires a library release.
|
|
41
|
+
*/
|
|
42
|
+
apiVersion?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Provider honors an `Idempotency-Key` request header. When set, the handler
|
|
45
|
+
* attaches one key per logical call and reuses it across retries, so a retry
|
|
46
|
+
* after a connection drops post-generation is deduped by the provider rather
|
|
47
|
+
* than billed twice. Left off unless the provider documents the header —
|
|
48
|
+
* sending an unknown header is harmless, but claiming the guarantee is not.
|
|
49
|
+
*/
|
|
50
|
+
supportsIdempotencyKey?: boolean;
|
|
37
51
|
};
|
|
38
52
|
listModelsPath?: string;
|
|
39
53
|
healthPath?: string;
|
|
@@ -42,20 +56,46 @@ export interface ProviderProfile {
|
|
|
42
56
|
const HOSTED_CLOUD: ProviderCapabilities = { nativeTools: true, jsonMode: true, local: false, embeddable: false };
|
|
43
57
|
const LOCAL_RUNTIME: ProviderCapabilities = { nativeTools: false, jsonMode: false, local: true, embeddable: true };
|
|
44
58
|
|
|
45
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The provider keys that have a profile in {@link PROVIDER_PROFILES}. Used to
|
|
61
|
+
* type `Connection.provider` so IDEs autocomplete valid names and typos are a
|
|
62
|
+
* compile error rather than a silent fall-through to `openai-compat`.
|
|
63
|
+
*/
|
|
64
|
+
export type KnownProvider =
|
|
65
|
+
| 'openai'
|
|
66
|
+
| 'azure-openai'
|
|
67
|
+
| 'openrouter'
|
|
68
|
+
| 'groq'
|
|
69
|
+
| 'deepseek'
|
|
70
|
+
| 'mistral'
|
|
71
|
+
| 'together'
|
|
72
|
+
| 'fireworks'
|
|
73
|
+
| 'cerebras'
|
|
74
|
+
| 'moonshot'
|
|
75
|
+
| 'cohere'
|
|
76
|
+
| 'perplexity'
|
|
77
|
+
| 'lmstudio'
|
|
78
|
+
| 'llamacpp'
|
|
79
|
+
| 'vllm'
|
|
80
|
+
| 'ollama'
|
|
81
|
+
| 'anthropic'
|
|
82
|
+
| 'google'
|
|
83
|
+
| 'openai-compat';
|
|
84
|
+
|
|
85
|
+
export const PROVIDER_PROFILES: Record<KnownProvider, ProviderProfile> & Record<string, ProviderProfile> = {
|
|
46
86
|
openai: {
|
|
47
87
|
engine: 'openaiChat',
|
|
48
88
|
defaultBaseUrl: 'https://api.openai.com/v1',
|
|
49
89
|
auth: 'bearer',
|
|
50
90
|
listModelsPath: '/models',
|
|
51
91
|
capabilities: HOSTED_CLOUD,
|
|
52
|
-
quirks: { supportsUsageInStream: true, maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
|
|
92
|
+
quirks: { supportsUsageInStream: true, maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true, supportsIdempotencyKey: true },
|
|
53
93
|
},
|
|
54
94
|
'azure-openai': {
|
|
55
95
|
engine: 'openaiChat',
|
|
56
96
|
auth: 'api-key-header',
|
|
57
97
|
capabilities: HOSTED_CLOUD,
|
|
58
|
-
quirks: { urlTemplate: '{baseUrl}/openai/deployments/{model}/chat/completions?api-version=2024-10-21', maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
|
|
98
|
+
quirks: { urlTemplate: '{baseUrl}/openai/deployments/{model}/chat/completions?api-version={apiVersion}', apiVersion: '2024-10-21', maxTokensParam: 'max_completion_tokens', supportsJsonSchema: true },
|
|
59
99
|
},
|
|
60
100
|
openrouter: {
|
|
61
101
|
engine: 'openaiChat',
|
|
@@ -71,6 +111,12 @@ export const PROVIDER_PROFILES: Record<string, ProviderProfile> = {
|
|
|
71
111
|
mistral: { engine: 'openaiChat', defaultBaseUrl: 'https://api.mistral.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
72
112
|
together: { engine: 'openaiChat', defaultBaseUrl: 'https://api.together.xyz/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
73
113
|
fireworks: { engine: 'openaiChat', defaultBaseUrl: 'https://api.fireworks.ai/inference/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
114
|
+
cerebras: { engine: 'openaiChat', defaultBaseUrl: 'https://api.cerebras.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
115
|
+
moonshot: { engine: 'openaiChat', defaultBaseUrl: 'https://api.moonshot.ai/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
116
|
+
// Cohere's OpenAI-compatible surface lives under /compatibility/v1.
|
|
117
|
+
cohere: { engine: 'openaiChat', defaultBaseUrl: 'https://api.cohere.ai/compatibility/v1', auth: 'bearer', listModelsPath: '/models', capabilities: HOSTED_CLOUD },
|
|
118
|
+
// Perplexity has no public model-listing endpoint, so listModels resolves empty.
|
|
119
|
+
perplexity: { engine: 'openaiChat', defaultBaseUrl: 'https://api.perplexity.ai', auth: 'bearer', capabilities: HOSTED_CLOUD },
|
|
74
120
|
lmstudio: {
|
|
75
121
|
engine: 'openaiChat',
|
|
76
122
|
defaultBaseUrl: 'http://localhost:1234/v1',
|
|
@@ -128,6 +174,10 @@ export const PROVIDER_PROFILES: Record<string, ProviderProfile> = {
|
|
|
128
174
|
// Unknown endpoint — usually pointed at a local/self-hosted server, so
|
|
129
175
|
// assume the conservative local-runtime defaults rather than the hosted-cloud ones.
|
|
130
176
|
capabilities: LOCAL_RUNTIME,
|
|
177
|
+
// Most servers reached through this escape hatch (JX Runtime and other
|
|
178
|
+
// local OpenAI-compatible runtimes) don't require a key at all; keep
|
|
179
|
+
// `auth: 'bearer'` for the ones that do (a key is only sent if provided).
|
|
180
|
+
quirks: { keyOptional: true },
|
|
131
181
|
},
|
|
132
182
|
};
|
|
133
183
|
|