@lihuu/dsh-ollama-cloud 0.1.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 +70 -0
- package/cordis.patch.yml +8 -0
- package/dist/index.js +837 -0
- package/lib/adapter.d.ts +111 -0
- package/lib/index.d.ts +62 -0
- package/lib/serialize.d.ts +44 -0
- package/lib/sse.d.ts +23 -0
- package/lib/translate.d.ts +32 -0
- package/lib/types.d.ts +143 -0
- package/package.json +52 -0
- package/src/adapter.ts +423 -0
- package/src/index.ts +214 -0
- package/src/serialize.ts +215 -0
- package/src/sse.ts +40 -0
- package/src/translate.ts +182 -0
- package/src/types.ts +148 -0
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize harness messages into an Ollama chat completions request.
|
|
3
|
+
* Text-only (the OpenAI-compatible endpoint's image path is deferred); tool
|
|
4
|
+
* results become standalone `role: 'tool'` messages. Reasoning is replayed as
|
|
5
|
+
* the `reasoning` assistant field only for reasoning-capable models (a wire id
|
|
6
|
+
* containing `deepseek`), so non-reasoning models keep clean traces.
|
|
7
|
+
* @module dsh-llm-ollama-cloud/serialize
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
|
|
11
|
+
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import type {
|
|
13
|
+
WireMessage,
|
|
14
|
+
WireRequest,
|
|
15
|
+
WireTool,
|
|
16
|
+
} from './types.ts'
|
|
17
|
+
|
|
18
|
+
/** Adapter-level request defaults (from plugin config). */
|
|
19
|
+
export interface RequestDefaults {
|
|
20
|
+
thinking?: 'enabled' | 'disabled' | undefined
|
|
21
|
+
reasoningEffort?: 'off' | 'low' | 'high' | 'max' | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The Ollama reasoning-effort values this adapter emits on the wire. */
|
|
25
|
+
export type WireReasoningEffort = 'none' | 'low' | 'high' | 'max'
|
|
26
|
+
|
|
27
|
+
interface ResolvedThinking {
|
|
28
|
+
reasoningEffort?: WireReasoningEffort
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a model's reasoning should be passed back on assistant history.
|
|
33
|
+
* Only reasoning-capable models accept the `reasoning` field; a non-reasoning
|
|
34
|
+
* model ignores it, so it is written only for wire ids containing `deepseek`.
|
|
35
|
+
* @param model - the wire model id.
|
|
36
|
+
* @returns true when the model is treated as reasoning-capable.
|
|
37
|
+
*/
|
|
38
|
+
export function passReasoning(model: string): boolean {
|
|
39
|
+
return model.includes('deepseek')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Validate the adapter-owned effort before resolving its Ollama wire value. */
|
|
43
|
+
function reasoningEffort(effort: NonNullable<GenerateOptions['reasoningEffort']>): 'off' | 'low' | 'high' | 'max' {
|
|
44
|
+
if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') {
|
|
45
|
+
return effort as 'off' | 'low' | 'high' | 'max'
|
|
46
|
+
}
|
|
47
|
+
throw new LlmError(
|
|
48
|
+
`Ollama does not support reasoning effort "${effort}"`,
|
|
49
|
+
'UNSUPPORTED_REASONING_EFFORT',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve one legal thinking/effort pair into an Ollama wire effort. An `off`
|
|
55
|
+
* (or a `disabled` deployment default) maps to `none`; an explicit effort maps
|
|
56
|
+
* to its Ollama spelling; an omitted effort with thinking enabled sends
|
|
57
|
+
* nothing so the server auto-enables thinking at its default.
|
|
58
|
+
* @param options - the harness request.
|
|
59
|
+
* @param defaults - adapter-level thinking defaults.
|
|
60
|
+
* @returns the wire `reasoning_effort`, or nothing when the server default should apply.
|
|
61
|
+
*/
|
|
62
|
+
function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking {
|
|
63
|
+
if (options.purpose === 'session-title') return { reasoningEffort: 'none' }
|
|
64
|
+
const effort = options.reasoningEffort === undefined
|
|
65
|
+
? defaults.reasoningEffort
|
|
66
|
+
: reasoningEffort(options.reasoningEffort)
|
|
67
|
+
if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') {
|
|
68
|
+
throw new LlmError(
|
|
69
|
+
`Ollama deployment does not support reasoning effort "${effort}"`,
|
|
70
|
+
'UNSUPPORTED_REASONING_EFFORT',
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
if (effort === 'off') return { reasoningEffort: 'none' }
|
|
74
|
+
if (effort === 'low' || effort === 'high' || effort === 'max') {
|
|
75
|
+
return { reasoningEffort: effort }
|
|
76
|
+
}
|
|
77
|
+
// effort undefined: disabled defaults suppress reasoning, enabled or unset
|
|
78
|
+
// ones send nothing and let the server pick its default.
|
|
79
|
+
return defaults.thinking === 'disabled' ? { reasoningEffort: 'none' } : {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Join the text blocks of a message (used for user/tool-result content). */
|
|
83
|
+
function flattenText(blocks: ContentBlock[]): string {
|
|
84
|
+
return blocks
|
|
85
|
+
.filter(block => block.type === 'text')
|
|
86
|
+
.map(block => block.text)
|
|
87
|
+
.join('')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reject core image content before any text-flattening path can silently erase it. */
|
|
91
|
+
function assertTextOnly(blocks: readonly ContentBlock[]): void {
|
|
92
|
+
if (contentHasImage(blocks)) {
|
|
93
|
+
throw new LlmError('The Ollama chat-completions adapter does not support image content yet.', 'UNSUPPORTED_CONTENT')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Serialize one assistant message (text + optional reasoning + tool calls). */
|
|
98
|
+
function serializeAssistant(message: Message, model: string): WireMessage {
|
|
99
|
+
const text = flattenText(message.content)
|
|
100
|
+
const reasoning = message.content
|
|
101
|
+
.filter(block => block.type === 'reasoning')
|
|
102
|
+
.map(block => block.text)
|
|
103
|
+
.join('')
|
|
104
|
+
const toolCalls = message.content
|
|
105
|
+
.filter(block => block.type === 'tool-call')
|
|
106
|
+
.map(block => ({
|
|
107
|
+
id: block.id,
|
|
108
|
+
type: 'function' as const,
|
|
109
|
+
function: { name: block.name, arguments: block.arguments },
|
|
110
|
+
}))
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
role: 'assistant',
|
|
114
|
+
// Text-less turns send "" — NEVER null. Reasoning-only turns (the model
|
|
115
|
+
// can answer entirely in the reasoning channel) risk a gateway 400, and
|
|
116
|
+
// since the message sits durably in the session log, a null here bricks
|
|
117
|
+
// every later turn of that session.
|
|
118
|
+
content: text,
|
|
119
|
+
// CoT passback only for reasoning-capable models, via the `reasoning`
|
|
120
|
+
// field Ollama accepts on assistant history.
|
|
121
|
+
...passReasoning(model) && reasoning.length > 0 ? { reasoning } : {},
|
|
122
|
+
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Serialize the conversation. `tool-result` blocks become standalone
|
|
128
|
+
* `{role: 'tool'}` messages; the harness puts each tool result in its own
|
|
129
|
+
* user-role message, so a mixed user message contributes its text first and
|
|
130
|
+
* its tool results as separate wire messages after.
|
|
131
|
+
* @param model - the wire model id, used to decide reasoning passback.
|
|
132
|
+
* @param messages - the harness conversation, in order.
|
|
133
|
+
* @returns the wire messages; order preserved, each tool result expanded into its own entry.
|
|
134
|
+
*/
|
|
135
|
+
export function serializeMessages(model: string, messages: Message[]): WireMessage[] {
|
|
136
|
+
const wire: WireMessage[] = []
|
|
137
|
+
for (const message of messages) {
|
|
138
|
+
assertTextOnly(message.content)
|
|
139
|
+
if (message.role === 'system') {
|
|
140
|
+
wire.push({ role: 'system', content: flattenText(message.content) })
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (message.role === 'assistant') {
|
|
144
|
+
wire.push(serializeAssistant(message, model))
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
// user role: tool results ride in user messages in the harness
|
|
148
|
+
// vocabulary, but Ollama wants them as role:'tool' messages.
|
|
149
|
+
const toolResults = message.content.filter(block => block.type === 'tool-result')
|
|
150
|
+
const text = flattenText(message.content)
|
|
151
|
+
if (text.length > 0 || toolResults.length === 0) {
|
|
152
|
+
wire.push({ role: 'user', content: text })
|
|
153
|
+
}
|
|
154
|
+
for (const result of toolResults) {
|
|
155
|
+
wire.push({
|
|
156
|
+
role: 'tool',
|
|
157
|
+
tool_call_id: result.toolCallId,
|
|
158
|
+
// Empty tool output still needs SOME content on the wire.
|
|
159
|
+
content: flattenText(result.content) || '(no output)',
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return wire
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Assemble request fields shared by every conversion. */
|
|
167
|
+
function requestWithMessages(
|
|
168
|
+
options: GenerateOptions,
|
|
169
|
+
messages: WireMessage[],
|
|
170
|
+
defaults: RequestDefaults,
|
|
171
|
+
): WireRequest {
|
|
172
|
+
const tools: WireTool[] | undefined = options.tools?.map(tool => ({
|
|
173
|
+
type: 'function',
|
|
174
|
+
function: {
|
|
175
|
+
name: tool.name,
|
|
176
|
+
description: tool.description,
|
|
177
|
+
parameters: tool.parameters,
|
|
178
|
+
},
|
|
179
|
+
}))
|
|
180
|
+
const resolvedThinking = resolveThinking(options, defaults)
|
|
181
|
+
return {
|
|
182
|
+
model: options.model,
|
|
183
|
+
messages,
|
|
184
|
+
stream: true,
|
|
185
|
+
stream_options: { include_usage: true },
|
|
186
|
+
...resolvedThinking.reasoningEffort !== undefined
|
|
187
|
+
? { reasoning_effort: resolvedThinking.reasoningEffort }
|
|
188
|
+
: {},
|
|
189
|
+
...tools !== undefined && tools.length > 0 ? { tools } : {},
|
|
190
|
+
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
|
191
|
+
...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
|
|
192
|
+
...options.stop !== undefined ? { stop: options.stop } : {},
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Build the full wire request. Always streaming (`stream: true`, usage
|
|
198
|
+
* reporting on); optional fields are omitted rather than sent as null, so
|
|
199
|
+
* provider defaults apply.
|
|
200
|
+
* @param options - the harness request (model, history, system, tools, sampling).
|
|
201
|
+
* @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
|
|
202
|
+
* @returns the chat-completions request body.
|
|
203
|
+
*/
|
|
204
|
+
export function serializeRequest(
|
|
205
|
+
options: GenerateOptions,
|
|
206
|
+
defaults: RequestDefaults = {},
|
|
207
|
+
): WireRequest {
|
|
208
|
+
const messages: WireMessage[] = []
|
|
209
|
+
if (options.system !== undefined) {
|
|
210
|
+
messages.push({ role: 'system', content: options.system })
|
|
211
|
+
}
|
|
212
|
+
messages.push(...serializeMessages(options.model, options.messages))
|
|
213
|
+
|
|
214
|
+
return requestWithMessages(options, messages, defaults)
|
|
215
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode an SSE byte stream into event `data` payloads. Framing — chunk
|
|
3
|
+
* reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping,
|
|
4
|
+
* multi-`data:` joining — is `eventsource-parser`'s. Comments are reported
|
|
5
|
+
* only through an optional transport-activity callback. This module keeps the
|
|
6
|
+
* OpenAI-compatible protocol: the literal `[DONE]` is yielded so the caller
|
|
7
|
+
* owns final flushing, and EOF before it raises {@link LlmError}. Framing is
|
|
8
|
+
* spec-strict: an event dispatches only on its blank-line terminator, so an
|
|
9
|
+
* unterminated tail at EOF is truncation, not a flushable payload.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-llm-ollama-cloud/sse
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { EventSourceParserStream } from 'eventsource-parser/stream'
|
|
15
|
+
import { LlmError } from '@deepseek-ai/dsh-llm'
|
|
16
|
+
|
|
17
|
+
/** The terminal payload OpenAI-compatible endpoints send after the last chunk. */
|
|
18
|
+
export const DONE = '[DONE]'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final
|
|
22
|
+
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
|
|
23
|
+
* without it (truncated response — the model call cannot be trusted).
|
|
24
|
+
* @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence.
|
|
25
|
+
* @param onComment - optional transport-activity callback; comments never enter the yielded payload stream.
|
|
26
|
+
* @returns each event's data payload in arrival order, the `[DONE]` sentinel last.
|
|
27
|
+
*/
|
|
28
|
+
export async function* parseSse(
|
|
29
|
+
stream: ReadableStream<BufferSource>,
|
|
30
|
+
onComment?: (comment: string) => void,
|
|
31
|
+
): AsyncGenerator<string> {
|
|
32
|
+
const events = stream
|
|
33
|
+
.pipeThrough(new TextDecoderStream())
|
|
34
|
+
.pipeThrough(new EventSourceParserStream({ onComment }))
|
|
35
|
+
for await (const { data } of events) {
|
|
36
|
+
yield data
|
|
37
|
+
if (data === DONE) return
|
|
38
|
+
}
|
|
39
|
+
throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED')
|
|
40
|
+
}
|
package/src/translate.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translate Ollama wire chunks into the harness `StreamChunk` protocol with
|
|
3
|
+
* one stateful harness block per content, reasoning, or tool call index. An
|
|
4
|
+
* empty initial reasoning delta does not open a block. Finish reason and the
|
|
5
|
+
* latest usage are deferred until `[DONE]`, covering both finish-attached and
|
|
6
|
+
* trailing usage-only shapes while ensuring no chunk follows `finish`.
|
|
7
|
+
* @module dsh-llm-ollama-cloud/translate
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ToolCallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
|
11
|
+
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
12
|
+
import { DONE } from './sse.ts'
|
|
13
|
+
import type { WireChunk, WireUsage } from './types.ts'
|
|
14
|
+
|
|
15
|
+
/** One open block under assembly. */
|
|
16
|
+
interface OpenBlock {
|
|
17
|
+
index: number
|
|
18
|
+
kind: 'text' | 'reasoning' | 'tool-call'
|
|
19
|
+
text: string
|
|
20
|
+
/** tool-call only */
|
|
21
|
+
callId?: string
|
|
22
|
+
name?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map the wire finish_reason vocabulary to the harness FinishReason.
|
|
27
|
+
* @param reason - the wire `finish_reason` string.
|
|
28
|
+
* @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`.
|
|
29
|
+
*/
|
|
30
|
+
export function mapFinishReason(reason: string): FinishReason {
|
|
31
|
+
switch (reason) {
|
|
32
|
+
case 'stop': return { kind: 'stop' }
|
|
33
|
+
case 'tool_calls': return { kind: 'tool-calls' }
|
|
34
|
+
case 'length': return { kind: 'max-tokens' }
|
|
35
|
+
default:
|
|
36
|
+
// content_filter, insufficient_system_resource, future additions.
|
|
37
|
+
return {
|
|
38
|
+
kind: 'error',
|
|
39
|
+
failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() },
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Map wire usage fields to the harness convention of DISJOINT counts. Cache
|
|
46
|
+
* hits and reasoning tokens are carried only when the wire reported them.
|
|
47
|
+
* @param usage - wire usage from the finish chunk or the trailing usage-only chunk.
|
|
48
|
+
* @returns disjoint harness counts; cache/reasoning fields present only when the wire reported them.
|
|
49
|
+
*/
|
|
50
|
+
export function mapUsage(usage: WireUsage): TokenUsage {
|
|
51
|
+
const cacheRead = usage.prompt_tokens_details?.cached_tokens
|
|
52
|
+
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
|
53
|
+
return {
|
|
54
|
+
inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
|
|
55
|
+
outputTokens: usage.completion_tokens,
|
|
56
|
+
...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {},
|
|
57
|
+
...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Assemble the final ContentBlock for one open block. */
|
|
62
|
+
function closeBlock(block: OpenBlock): ContentBlock {
|
|
63
|
+
switch (block.kind) {
|
|
64
|
+
case 'text': return { type: 'text', text: block.text }
|
|
65
|
+
case 'reasoning': return { type: 'reasoning', text: block.text }
|
|
66
|
+
case 'tool-call': return {
|
|
67
|
+
type: 'tool-call',
|
|
68
|
+
id: ToolCallId(block.callId ?? ''),
|
|
69
|
+
name: block.name ?? '',
|
|
70
|
+
arguments: block.text,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
|
|
77
|
+
* Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`.
|
|
78
|
+
* @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated.
|
|
79
|
+
* @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel.
|
|
80
|
+
* A `stop` (or absent) finish with no opened blocks is a degenerate provider completion and maps to an
|
|
81
|
+
* `EMPTY_RESPONSE` error finish instead of a successful empty message.
|
|
82
|
+
*/
|
|
83
|
+
export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
|
|
84
|
+
let nextIndex = 0
|
|
85
|
+
let textBlock: OpenBlock | undefined
|
|
86
|
+
let reasoningBlock: OpenBlock | undefined
|
|
87
|
+
const toolBlocks = new Map<number, OpenBlock>()
|
|
88
|
+
const order: OpenBlock[] = []
|
|
89
|
+
let pendingFinish: FinishReason | undefined
|
|
90
|
+
let pendingUsage: TokenUsage | undefined
|
|
91
|
+
|
|
92
|
+
function open(kind: OpenBlock['kind']): OpenBlock {
|
|
93
|
+
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
|
|
94
|
+
order.push(block)
|
|
95
|
+
return block
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for await (const payload of payloads) {
|
|
99
|
+
if (payload === DONE) {
|
|
100
|
+
for (const block of order) {
|
|
101
|
+
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
|
102
|
+
}
|
|
103
|
+
if (pendingUsage) yield { type: 'usage', usage: pendingUsage }
|
|
104
|
+
const reason = pendingFinish ?? { kind: 'stop' as const }
|
|
105
|
+
yield {
|
|
106
|
+
type: 'finish',
|
|
107
|
+
reason: reason.kind === 'stop' && order.length === 0
|
|
108
|
+
? {
|
|
109
|
+
kind: 'error',
|
|
110
|
+
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
|
|
111
|
+
}
|
|
112
|
+
: reason,
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let chunk: WireChunk
|
|
118
|
+
try {
|
|
119
|
+
chunk = JSON.parse(payload) as WireChunk
|
|
120
|
+
} catch {
|
|
121
|
+
throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const choice of chunk.choices ?? []) {
|
|
125
|
+
const delta = choice.delta
|
|
126
|
+
|
|
127
|
+
// Reasoning first: thinking mode interleaves it before text. The
|
|
128
|
+
// empty-string first chunk must not open a block.
|
|
129
|
+
const reasoning = delta?.reasoning
|
|
130
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
|
131
|
+
if (!reasoningBlock) {
|
|
132
|
+
reasoningBlock = open('reasoning')
|
|
133
|
+
yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' }
|
|
134
|
+
}
|
|
135
|
+
reasoningBlock.text += reasoning
|
|
136
|
+
yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const content = delta?.content
|
|
140
|
+
if (typeof content === 'string' && content.length > 0) {
|
|
141
|
+
if (!textBlock) {
|
|
142
|
+
textBlock = open('text')
|
|
143
|
+
yield { type: 'block-start', index: textBlock.index, blockType: 'text' }
|
|
144
|
+
}
|
|
145
|
+
textBlock.text += content
|
|
146
|
+
yield { type: 'text-delta', index: textBlock.index, text: content }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const call of delta?.tool_calls ?? []) {
|
|
150
|
+
let block = toolBlocks.get(call.index)
|
|
151
|
+
if (!block) {
|
|
152
|
+
block = open('tool-call')
|
|
153
|
+
toolBlocks.set(call.index, block)
|
|
154
|
+
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
|
155
|
+
}
|
|
156
|
+
if (call.id !== undefined) block.callId = call.id
|
|
157
|
+
if (call.function?.name !== undefined) block.name = call.function.name
|
|
158
|
+
const fragment = call.function?.arguments ?? ''
|
|
159
|
+
block.text += fragment
|
|
160
|
+
yield {
|
|
161
|
+
type: 'tool-call-delta',
|
|
162
|
+
index: block.index,
|
|
163
|
+
id: ToolCallId(block.callId ?? ''),
|
|
164
|
+
...block.name !== undefined ? { name: block.name } : {},
|
|
165
|
+
argumentsDelta: fragment,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (typeof choice.finish_reason === 'string') {
|
|
170
|
+
pendingFinish = mapFinishReason(choice.finish_reason)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Usage may arrive attached to the finish chunk or as a trailing
|
|
175
|
+
// usage-only chunk — keep the latest.
|
|
176
|
+
if (chunk.usage) pendingUsage = mapUsage(chunk.usage)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// parseSse guarantees the [DONE] sentinel (or throws); reaching here means
|
|
180
|
+
// the payload source violated that contract.
|
|
181
|
+
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
|
|
182
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama chat-completions wire format (OpenAI-compatible). Types only.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: Ollama's OpenAI-compatible `/v1/chat/completions` endpoint
|
|
5
|
+
* (https://ollama.com) plus the empirical behaviour of local Ollama serving
|
|
6
|
+
* `deepseek-v4-flash:cloud` (2026-08). Reasoning reaches the model in the
|
|
7
|
+
* `reasoning` delta field (not `reasoning_content`), and reasoning effort is a
|
|
8
|
+
* single `reasoning_effort` value (`none`/`low`/`medium`/`high`/`max`) rather
|
|
9
|
+
* than a separate `thinking` toggle: omitting the field auto-enables thinking
|
|
10
|
+
* at the server default, while `none` disables it.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-llm-ollama-cloud/types
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Request body for `POST {baseURL}/chat/completions`. */
|
|
16
|
+
export interface WireRequest {
|
|
17
|
+
model: string
|
|
18
|
+
messages: WireMessage[]
|
|
19
|
+
stream: true
|
|
20
|
+
stream_options: { include_usage: true }
|
|
21
|
+
/** Thinking effort; `none` disables reasoning and an omission uses the provider default. */
|
|
22
|
+
reasoning_effort?: 'none' | 'low' | 'medium' | 'high' | 'max'
|
|
23
|
+
tools?: WireTool[]
|
|
24
|
+
temperature?: number
|
|
25
|
+
max_tokens?: number
|
|
26
|
+
/** Stop sequences (OpenAI `stop`); generation halts as soon as the model produces one. */
|
|
27
|
+
stop?: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** System-role message: a single string of instructions. */
|
|
31
|
+
export interface WireSystemMessage {
|
|
32
|
+
role: 'system'
|
|
33
|
+
content: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** User-role message; Ollama accepts a plain string for text-only input. */
|
|
37
|
+
export interface WireUserMessage {
|
|
38
|
+
role: 'user'
|
|
39
|
+
content: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Tool-role message: the result of one tool call, keyed by its call id. */
|
|
43
|
+
export interface WireToolMessage {
|
|
44
|
+
role: 'tool'
|
|
45
|
+
tool_call_id: string
|
|
46
|
+
content: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One entry of the request `messages` array, discriminated on `role`. */
|
|
50
|
+
export type WireMessage =
|
|
51
|
+
| WireSystemMessage
|
|
52
|
+
| WireUserMessage
|
|
53
|
+
| WireAssistantMessage
|
|
54
|
+
| WireToolMessage
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Assistant-role history message. The harness replays `content: ""` (never
|
|
58
|
+
* null) on tool-call-only turns — some gateways reject null — and sends null
|
|
59
|
+
* only when the turn carried neither text nor tool calls.
|
|
60
|
+
*/
|
|
61
|
+
export interface WireAssistantMessage {
|
|
62
|
+
role: 'assistant'
|
|
63
|
+
content: string | null
|
|
64
|
+
/**
|
|
65
|
+
* CoT passback, present only on a turn whose assistant content carried
|
|
66
|
+
* reasoning AND whose model is reasoning-capable (a wire id containing
|
|
67
|
+
* `deepseek`). Ollama accepts the `reasoning` field on assistant history
|
|
68
|
+
* messages; a non-reasoning model simply ignores it, so it is only written
|
|
69
|
+
* for reasoning-capable models to keep non-reasoning traces clean.
|
|
70
|
+
*/
|
|
71
|
+
reasoning?: string
|
|
72
|
+
tool_calls?: WireToolCall[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A completed tool call replayed on an assistant history message; `arguments` is the raw JSON string. */
|
|
76
|
+
export interface WireToolCall {
|
|
77
|
+
id: string
|
|
78
|
+
type: 'function'
|
|
79
|
+
function: { name: string; arguments: string }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One entry of the request `tools` array; `parameters` is a JSON Schema object. */
|
|
83
|
+
export interface WireTool {
|
|
84
|
+
type: 'function'
|
|
85
|
+
function: {
|
|
86
|
+
name: string
|
|
87
|
+
description: string
|
|
88
|
+
parameters: Record<string, unknown>
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One parsed SSE `data:` payload (a chat.completion.chunk). */
|
|
93
|
+
export interface WireChunk {
|
|
94
|
+
choices?: WireChoice[]
|
|
95
|
+
/** Arrives attached to the finish chunk and/or as a trailing usage-only chunk. */
|
|
96
|
+
usage?: WireUsage | null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One streamed choice (requests always ask for a single one); `finish_reason` is non-null only on its terminal chunk. */
|
|
100
|
+
export interface WireChoice {
|
|
101
|
+
delta?: WireDelta
|
|
102
|
+
finish_reason?: string | null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The incremental content of one streamed choice; any subset of fields may be present per chunk. */
|
|
106
|
+
export interface WireDelta {
|
|
107
|
+
role?: string
|
|
108
|
+
/** Visible text. Null/empty on reasoning/tool-call chunks. */
|
|
109
|
+
content?: string | null
|
|
110
|
+
/**
|
|
111
|
+
* Thinking-mode CoT. The FIRST chunk carries an empty string (must not open
|
|
112
|
+
* a reasoning block); absent entirely in non-thinking mode.
|
|
113
|
+
*/
|
|
114
|
+
reasoning?: string | null
|
|
115
|
+
tool_calls?: WireToolCallDelta[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A streamed fragment of one tool call; fragments sharing an `index` concatenate into one call. */
|
|
119
|
+
export interface WireToolCallDelta {
|
|
120
|
+
/** Disambiguates parallel tool calls; stable across a call's deltas. */
|
|
121
|
+
index: number
|
|
122
|
+
/** Present on the first delta of each call only. */
|
|
123
|
+
id?: string
|
|
124
|
+
type?: 'function'
|
|
125
|
+
function?: {
|
|
126
|
+
/** Present on the first delta of each call only. */
|
|
127
|
+
name?: string
|
|
128
|
+
/** Argument JSON fragment (concatenate across deltas). */
|
|
129
|
+
arguments?: string
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Wire token accounting. Ollama's OpenAI-compatible endpoint reports standard
|
|
135
|
+
* `prompt_tokens`/`completion_tokens`; the cache and reasoning details follow
|
|
136
|
+
* the OpenAI-compat spelling when the backend supplies them.
|
|
137
|
+
*/
|
|
138
|
+
export interface WireUsage {
|
|
139
|
+
prompt_tokens: number
|
|
140
|
+
completion_tokens: number
|
|
141
|
+
prompt_tokens_details?: { cached_tokens?: number }
|
|
142
|
+
completion_tokens_details?: { reasoning_tokens?: number }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Non-2xx error body. */
|
|
146
|
+
export interface WireError {
|
|
147
|
+
error?: { message?: string; type?: string; code?: string }
|
|
148
|
+
}
|