@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.
@@ -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
+ }
@@ -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
+ }