Compare commits

...

47 Commits

Author SHA1 Message Date
Bruce MacDonald 9c92b18d40 fix: release ReadableStream reader after iteration completes (#277)
The parseJSON function obtained a ReadableStreamDefaultReader but never called
releaseLock() when iteration finished. This caused Deno's test runner to detect a memory leak with streaming responses.
2026-02-18 14:06:18 -08:00
Jeffrey Morgan f23d7eeb6d examples: fix imagegen first step printing (#273) 2026-01-23 11:44:47 -08:00
Jeffrey Morgan ef411aa67e clean up examples readme (#272) 2026-01-22 22:55:05 -08:00
Jeffrey Morgan c8f3fb3b43 Add image generation support (#271) 2026-01-22 22:45:39 -08:00
lif f7827ba69c browser: export AbortableAsyncIterator type (#267)
Export AbortableAsyncIterator type from the browser module to allow
users to import this type when using ollama/browser.

Fixes #135

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-05 14:31:24 -08:00
Jag Reehal 133f3623a1 Add min_p parameter to Options interface (#265)
Adds the min_p (minimum probability threshold) parameter to the Options
interface. This parameter is supported by the Ollama API but was missing
from the TypeScript definitions.

min_p works alongside top_p to control token selection during generation
by setting a minimum probability threshold relative to the most likely token.
Tokens with probabilities below this threshold are filtered out.

This addresses the missing parameter mentioned in issue #145.
2025-12-12 16:40:25 -08:00
Kaloyan Stoyanov a667d4d651 browser/interfaces: add VersionResponse type and add ollama.version() to README (#261) 2025-11-13 11:21:46 -08:00
Parth Sareen c3b668c453 browser/interfaces: add logprobs (#260) 2025-11-12 12:21:14 -08:00
Kaloyan Stoyanov 75baea068e browser: add method to retrieve server version (#259) 2025-11-12 12:10:11 -08:00
Bruce MacDonald 603df9fe59 Update publish.yaml 2025-10-30 13:02:49 -07:00
Bruce MacDonald b4acbee8a0 Revert "fix: regenerate package-lock.json with complete @swc/core platform entries (#257)"
This reverts commit 5b54730c8b.
2025-10-30 13:02:08 -07:00
Bruce MacDonald 5b54730c8b fix: regenerate package-lock.json with complete @swc/core platform entries (#257)
Fixes npm ci failure caused by stricter lockfile validation in newer npm versions.
The lockfile was missing node_modules entries for platform-specific optional dependencies.
2025-10-30 12:19:59 -07:00
Bruce MacDonald 5a132f678d fix: streaming chunk boundaries (#256)
TextDecoder.decode() without { stream: true } treats each chunk as
a complete sequence. When a multibyte UTF-8 character (e.g., 'ь' =
0xD1 0x8C) is split across chunks, the first chunk's incomplete bytes
are emitted as replacement characters instead of being buffered.
2025-10-30 12:04:25 -07:00
Sean Gallen 3b8db716b8 remove duplicate line in .npmignore (#254) 2025-10-22 21:43:30 -07:00
Adrian 9dc9716ece Update multi-tool.ts imports (#231)
Fixes `ReferenceError: Ollama is not defined` error
2025-10-16 09:54:29 -07:00
Eden Chan de292ee84f docs(readme): add Cloud Models JS usage and Cloud API example (#253)
add Cloud Models usage for JavaScript and Cloud API example\n\n- Add local offload flow (signin, pull, run)\n- Add direct cloud API usage with auth\n- List supported cloud model IDs\n- Keep examples minimal; match existing style
2025-10-16 09:44:33 -07:00
Parth Sareen 5f33c960f2 examples: rename browser tool to gpt-oss-browser-tools (#251) 2025-09-24 21:45:07 -07:00
Parth Sareen a6689ac591 browser/utils: updates for web search and loading OLLAMA_API_KEY from the environment (#250)
---------

Co-authored-by: jmorganca <jmorganca@gmail.com>
2025-09-24 17:20:39 -07:00
nicole pardal 0ce6961552 readme: added webSearch + webFetch (#245) 2025-09-24 16:36:45 -07:00
nicole pardal 0dd23f9fe1 examples: browser tool implementation (#244) 2025-09-24 15:50:35 -07:00
nicole pardal cfb069eaf2 browser: update webCrawl and webFetch shapes (#249) 2025-09-23 10:56:08 -07:00
nicole pardal 495cef8628 renamed websearch/webcrawl to camelcase (#248) 2025-09-18 18:00:43 -07:00
nicole pardal ab1e7a8ea3 browser: renamed to websearch/webcrawl (#247) 2025-09-17 18:10:54 -07:00
nicole pardal 5267b5632a browser/interface: add websearch + webcrawl (#243) 2025-09-17 15:42:47 -07:00
Michael Yang be27a947a2 feat: add support for embedding dimensions parameter (#242) 2025-09-15 15:24:31 -07:00
Devon Rifkin 60bfed19ef Merge pull request #239 from ollama/drifkin/thinking-levels
add support for thinking levels
2025-08-07 15:44:32 -07:00
Devon Rifkin c84c15bb2f add support for thinking levels
For supported models (currently only the gpt-oss models), we allow
passing in strings for the thinking level. As more models add support
for this sort of thing, we'll probably relax which strings can be
provided
2025-08-07 15:40:10 -07:00
Parth Sareen 45d2e0197e types: add tool_name to message (#229) 2025-07-07 16:45:53 -07:00
Jeffrey Morgan 603457c2aa add documentation on overriding headers (#226) 2025-06-24 17:08:28 -07:00
Devon Rifkin c9793cc644 Merge pull request #223 from ollama/drifkin/thinking-support
add thinking support and examples
2025-05-29 19:39:14 -07:00
Devon Rifkin f38831b896 update docs 2025-05-27 11:32:29 -07:00
Devon Rifkin b6a8868dd5 add thinking support and examples 2025-05-27 00:32:26 -07:00
Tushar Selvakumar 83682a7990 feat: updated show response type to include capabilities (#222) 2025-05-23 11:47:42 -07:00
Somprasong Damyos 6a4bfe3ab0 fix(formatHost): support basic auth in host URL (#219) 2025-04-14 11:29:03 -07:00
Parth Sareen e4f9c8f948 interfaces: allow for $defs and items in Tool interface (#218) 2025-04-09 19:29:07 -07:00
Parth Sareen 28fb3e6d03 interface: relax enum for tool and allow multiple property types (#217) 2025-04-08 10:37:19 -07:00
Pieter Ouwerkerk b24558558f Add missing embed response properties (#215) 2025-04-07 11:45:10 -04:00
Bruce MacDonald 99293abe2c utils: handle userAgentData browser compatibility (#210)
Use runtime checks and minimal type assertion to safely handle the
userAgentData API which is not yet widely supported across browsers.
This maintains TypeScript type safety while ensuring the platform
detection works in both modern and legacy browsers.
2025-02-24 10:44:43 -08:00
Yoann Moinet ff96b38579 utils: fallback for deprecated window.navigator.platform (#208) 2025-02-20 13:03:57 -08:00
Bruce MacDonald 5a0fadadcf ci: split builds and testing (#209) 2025-02-20 12:16:33 -08:00
AlexanderSlaa 5007ce7909 fix: Normalize header handling for all HeaderInit types (#203) 2025-02-20 09:52:33 -08:00
rising 9320655174 examples: add top-level await in examples (#206) 2025-02-07 14:36:10 -08:00
Dan e2eac5b3bc interfaces: add model field to the ModelResponse interface (#190) 2025-02-07 14:25:04 -08:00
Koushik Roy be880778c7 Fix: Use node: prefix for built-in modules
Update the import statements for the fs and path modules to use the node: prefix. This is the recommended modern way to import Node.js core modules and improves code clarity, prevents potential naming conflicts, and enhances future compatibility. It also can sometimes help with tree-shaking in bundlers.
2025-02-02 20:32:38 -08:00
Bruce MacDonald 6b262d6e9c ci: pin older version of vitest for node 16 compatibility (#200) 2025-01-28 10:52:43 -08:00
Abdulrahman Goni 9ff1621535 refactor: put the default host in a variable and then reuse it (#197)
The default host 'http://127.0.0.1:11434' is hardcoded in
multiple places, So, Put it in one variable in a central place
(`src/defaultHost.ts` file) and reuse it in those places.
2025-01-28 10:36:00 -08:00
Bruce MacDonald b95d44c005 docs: remove create example from README (#199)
This got missed during API updates.
2025-01-27 10:00:44 -08:00
25 changed files with 2094 additions and 84 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: latest
node-version: '20'
registry-url: https://registry.npmjs.org
cache: npm
- run: npm ci
+17 -5
View File
@@ -4,7 +4,7 @@ on:
pull_request:
jobs:
test:
build:
strategy:
matrix:
node-version: ['16', '18', '20']
@@ -16,11 +16,23 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm i -D @vitest/coverage-v8
- run: npm run build
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm i -D @vitest/coverage-v8@^2.1.6
- run: npm run lint
- run: npm test -- --coverage
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: vitest-results-${{ matrix.node-version }}
name: vitest-results
path: coverage/*
if: ${{ always() }}
if: ${{ always() }}
-1
View File
@@ -2,7 +2,6 @@ node_modules
build
.docs
.coverage
node_modules
package-lock.json
yarn.lock
.vscode
+128 -9
View File
@@ -21,7 +21,9 @@ console.log(response.message.content)
```
### Browser Usage
To use the library without node, import the browser module.
```javascript
import ollama from 'ollama/browser'
```
@@ -34,22 +36,81 @@ Response streaming can be enabled by setting `stream: true`, modifying function
import ollama from 'ollama'
const message = { role: 'user', content: 'Why is the sky blue?' }
const response = await ollama.chat({ model: 'llama3.1', messages: [message], stream: true })
const response = await ollama.chat({
model: 'llama3.1',
messages: [message],
stream: true,
})
for await (const part of response) {
process.stdout.write(part.message.content)
}
```
## Create
## Cloud Models
Run larger models by offloading to Ollamas cloud while keeping your local workflow.
[You can see models currently available on Ollama's cloud here.](https://ollama.com/search?c=cloud)
### Run via local Ollama
1) Sign in (one-time):
```
ollama signin
```
2) Pull a cloud model:
```
ollama pull gpt-oss:120b-cloud
```
3) Use as usual (offloads automatically):
```javascript
import ollama from 'ollama'
import { Ollama } from 'ollama'
const modelfile = `
FROM llama3.1
SYSTEM "You are mario from super mario bros."
`
await ollama.create({ model: 'example', modelfile: modelfile })
const ollama = new Ollama()
const response = await ollama.chat({
model: 'gpt-oss:120b-cloud',
messages: [{ role: 'user', content: 'Explain quantum computing' }],
stream: true,
})
for await (const part of response) {
process.stdout.write(part.message.content)
}
```
### Cloud API (ollama.com)
Access cloud models directly by pointing the client at `https://ollama.com`.
1) Create an [API key](https://ollama.com/settings/keys), then set the `OLLAMA_API_KEY` environment variable:
```
export OLLAMA_API_KEY=your_api_key
```
2) Generate a response via the cloud API:
```javascript
import { Ollama } from 'ollama'
const ollama = new Ollama({
host: 'https://ollama.com',
headers: { Authorization: 'Bearer ' + process.env.OLLAMA_API_KEY },
})
const response = await ollama.chat({
model: 'gpt-oss:120b',
messages: [{ role: 'user', content: 'Explain quantum computing' }],
stream: true,
})
for await (const part of response) {
process.stdout.write(part.message.content)
}
```
## API
@@ -69,8 +130,12 @@ ollama.chat(request)
- `role` `<string>`: The role of the message sender ('user', 'system', or 'assistant').
- `content` `<string>`: The content of the message.
- `images` `<Uint8Array[] | string[]>`: (Optional) Images to be included in the message, either as Uint8Array or base64 encoded strings.
- `tool_name` `<string>`: (Optional) Add the name of the tool that was executed to inform the model of the result
- `format` `<string>`: (Optional) Set the expected format of the response (`json`).
- `stream` `<boolean>`: (Optional) When true an `AsyncGenerator` is returned.
- `think` `<boolean | "high" | "medium" | "low">`: (Optional) Enable model thinking. Use `true`/`false` or specify a level. Requires model support.
- `logprobs` `<boolean>`: (Optional) Return log probabilities for tokens. Requires model support.
- `top_logprobs` `<number>`: (Optional) Number of top log probabilities to return per token when `logprobs` is enabled.
- `keep_alive` `<string | number>`: (Optional) How long to keep the model loaded. A number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc.)
- `tools` `<Tool[]>`: (Optional) A list of tool calls the model may make.
- `options` `<Options>`: (Optional) Options to configure the runtime.
@@ -93,7 +158,13 @@ ollama.generate(request)
- `images` `<Uint8Array[] | string[]>`: (Optional) Images to be included, either as Uint8Array or base64 encoded strings.
- `format` `<string>`: (Optional) Set the expected format of the response (`json`).
- `stream` `<boolean>`: (Optional) When true an `AsyncGenerator` is returned.
- `think` `<boolean | "high" | "medium" | "low">`: (Optional) Enable model thinking. Use `true`/`false` or specify a level. Requires model support.
- `logprobs` `<boolean>`: (Optional) Return log probabilities for tokens. Requires model support.
- `top_logprobs` `<number>`: (Optional) Number of top log probabilities to return per token when `logprobs` is enabled.
- `keep_alive` `<string | number>`: (Optional) How long to keep the model loaded. A number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc.)
- `width` `<number>`: (Optional, Experimental) Width of the generated image in pixels. For image generation models only.
- `height` `<number>`: (Optional, Experimental) Height of the generated image in pixels. For image generation models only.
- `steps` `<number>`: (Optional, Experimental) Number of diffusion steps. For image generation models only.
- `options` `<Options>`: (Optional) Options to configure the runtime.
- Returns: `<GenerateResponse>`
@@ -198,6 +269,28 @@ ollama.embed(request)
- `options` `<Options>`: (Optional) Options to configure the runtime.
- Returns: `<EmbedResponse>`
### web search
- Web search capability requires an Ollama account. [Sign up on ollama.com](https://ollama.com/signup)
- Create an API key by visiting https://ollama.com/settings/keys
```javascript
ollama.webSearch(request)
```
- `request` `<Object>`: The search request parameters.
- `query` `<string>`: The search query string.
- `max_results` `<number>`: (Optional) Maximum results to return (default 5, max 10).
- Returns: `<SearchResponse>`
### web fetch
```javascript
ollama.webFetch(request)
```
- `request` `<Object>`: The fetch request parameters.
- `url` `<string>`: The URL to fetch.
- Returns: `<FetchResponse>`
### ps
```javascript
@@ -206,6 +299,14 @@ ollama.ps()
- Returns: `<ListResponse>`
### version
```javascript
ollama.version()
```
- Returns: `<VersionResponse>`
### abort
```javascript
@@ -215,7 +316,7 @@ ollama.abort()
This method will abort **all** streamed generations currently running with the client instance.
If there is a need to manage streams with timeouts, it is recommended to have one Ollama client per stream.
All asynchronous threads listening to streams (typically the ```for await (const part of response)```) will throw an ```AbortError``` exception. See [examples/abort/abort-all-requests.ts](examples/abort/abort-all-requests.ts) for an example.
All asynchronous threads listening to streams (typically the `for await (const part of response)`) will throw an `AbortError` exception. See [examples/abort/abort-all-requests.ts](examples/abort/abort-all-requests.ts) for an example.
## Custom client
@@ -223,6 +324,7 @@ A custom client can be created with the following fields:
- `host` `<string>`: (Optional) The Ollama host address. Default: `"http://127.0.0.1:11434"`.
- `fetch` `<Object>`: (Optional) The fetch library used to make requests to the Ollama host.
- `headers` `<Object>`: (Optional) Custom headers to include with every request.
```javascript
import { Ollama } from 'ollama'
@@ -234,6 +336,23 @@ const response = await ollama.chat({
})
```
## Custom Headers
You can set custom headers that will be included with every request:
```javascript
import { Ollama } from 'ollama'
const ollama = new Ollama({
host: 'http://127.0.0.1:11434',
headers: {
Authorization: 'Bearer <api key>',
'X-Custom-Header': 'custom-value',
'User-Agent': 'MyApp/1.0',
},
})
```
## Building
To build the project files run:
+7 -1
View File
@@ -1,10 +1,16 @@
## Examples
> [!IMPORTANT]
> Note: Ensure that `npm build` has been run before running the examples.
> Note: Ensure that `npm run build` has been run before running the examples.
To run the examples run:
```sh
npx tsx <folder-name>/<file-name>.ts
```
### Image Generation (Experimental)
> **Note:** Image generation is experimental and currently only available on macOS.
- [image-generation/image-generation.ts](image-generation/image-generation.ts)
+10 -6
View File
@@ -1,8 +1,12 @@
import ollama from 'ollama'
const response = await ollama.generate({
model: 'deepseek-coder-v2',
prompt: `def add(`,
suffix: `return c`,
})
console.log(response.response)
async function main() {
const response = await ollama.generate({
model: 'deepseek-coder-v2',
prompt: `def add(`,
suffix: `return c`,
})
console.log(response.response)
}
main().catch(console.error)
@@ -0,0 +1,29 @@
// Image generation is experimental and currently only available on macOS
import ollama from 'ollama'
import { writeFileSync } from 'fs'
async function main() {
const prompt = 'a sunset over mountains'
console.log(`Prompt: ${prompt}`)
const response = await ollama.generate({
model: 'x/z-image-turbo',
prompt,
stream: true,
})
for await (const part of response) {
if (part.image) {
// Final response contains the image
const imageBuffer = Buffer.from(part.image, 'base64')
writeFileSync('output.png', imageBuffer)
console.log('\nImage saved to output.png')
} else if (part.total) {
// Progress update
process.stdout.write(`\rProgress: ${part.completed ?? 0}/${part.total}`)
}
}
}
main().catch(console.error)
+31
View File
@@ -0,0 +1,31 @@
import { Ollama, Logprob } from 'ollama';
function printLogprobs(entries: Logprob[], label: string) {
console.log(`\n${label}:`)
for (const entry of entries) {
console.log(` token=${entry.token.padEnd(12)} logprob=${entry.logprob.toFixed(3)}`)
for (const alt of entry.top_logprobs ?? []) {
console.log(` alt -> ${alt.token.padEnd(12)} (${alt.logprob.toFixed(3)})`)
}
}
}
async function main() {
const client = new Ollama()
console.log(`Using model: gemma3`)
const chatResponse = await client.chat({
model: 'gemma3',
messages: [{ role: 'user', content: 'Say hello in one word.' }],
logprobs: true,
top_logprobs: 3,
})
console.log('Chat response:', chatResponse.message.content)
printLogprobs(chatResponse.logprobs ?? [], 'chat logprobs')
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})
+13 -9
View File
@@ -1,12 +1,16 @@
import ollama from 'ollama'
const imagePath = './examples/multimodal/cat.jpg'
const response = await ollama.generate({
model: 'llava',
prompt: 'describe this image:',
images: [imagePath],
stream: true,
})
for await (const part of response) {
process.stdout.write(part.response)
async function main() {
const imagePath = './examples/multimodal/cat.jpg'
const response = await ollama.generate({
model: 'llava',
prompt: 'describe this image:',
images: [imagePath],
stream: true,
})
for await (const part of response) {
process.stdout.write(part.response)
}
}
main().catch(console.error)
+23 -19
View File
@@ -1,25 +1,29 @@
import ollama from 'ollama'
const model = 'llama3.1'
console.log(`downloading ${model}...`)
let currentDigestDone = false
const stream = await ollama.pull({ model: model, stream: true })
for await (const part of stream) {
if (part.digest) {
let percent = 0
if (part.completed && part.total) {
percent = Math.round((part.completed / part.total) * 100)
}
process.stdout.clearLine(0) // Clear the current line
process.stdout.cursorTo(0) // Move cursor to the beginning of the line
process.stdout.write(`${part.status} ${percent}%...`) // Write the new text
if (percent === 100 && !currentDigestDone) {
console.log() // Output to a new line
currentDigestDone = true
async function main() {
const model = 'llama3.1'
console.log(`downloading ${model}...`)
let currentDigestDone = false
const stream = await ollama.pull({ model: model, stream: true })
for await (const part of stream) {
if (part.digest) {
let percent = 0
if (part.completed && part.total) {
percent = Math.round((part.completed / part.total) * 100)
}
process.stdout.clearLine(0) // Clear the current line
process.stdout.cursorTo(0) // Move cursor to the beginning of the line
process.stdout.write(`${part.status} ${percent}%...`) // Write the new text
if (percent === 100 && !currentDigestDone) {
console.log() // Output to a new line
currentDigestDone = true
} else {
currentDigestDone = false
}
} else {
currentDigestDone = false
console.log(part.status)
}
} else {
console.log(part.status)
}
}
main().catch(console.error)
+20
View File
@@ -0,0 +1,20 @@
import ollama from 'ollama'
async function main() {
const response = await ollama.chat({
model: 'deepseek-r1',
messages: [
{
role: 'user',
content: 'What is 10 + 23',
},
],
stream: false,
think: true,
})
console.log('Thinking:\n========\n\n' + response.message.thinking)
console.log('\nResponse:\n========\n\n' + response.message.content + '\n\n')
}
main()
+36
View File
@@ -0,0 +1,36 @@
import ollama from 'ollama'
function printHeading(text: string) {
console.log(text)
console.log('='.repeat(text.length))
}
async function main() {
const messages = [{ role: 'user', content: 'What is 10 + 23?' }]
// gpt-oss supports 'low', 'medium', 'high'
const thinkingLevels = ['low', 'medium', 'high'] as const
for (const [index, level] of thinkingLevels.entries()) {
const response = await ollama.chat({
model: 'gpt-oss:20b',
messages,
think: level,
})
printHeading(`Thinking (${level})`)
console.log(response.message.thinking ?? '')
console.log('\n')
printHeading('Response')
console.log(response.message.content)
console.log('\n')
if (index < thinkingLevels.length - 1) {
console.log('-'.repeat(20))
console.log('\n')
}
}
}
main()
+36
View File
@@ -0,0 +1,36 @@
import ollama from 'ollama'
async function main() {
const response = await ollama.chat({
model: 'deepseek-r1',
messages: [
{
role: 'user',
content: 'What is 10 + 23',
},
],
stream: true,
think: true,
})
let startedThinking = false
let finishedThinking = false
for await (const chunk of response) {
if (chunk.message.thinking && !startedThinking) {
startedThinking = true
process.stdout.write('Thinking:\n========\n\n')
} else if (chunk.message.content && startedThinking && !finishedThinking) {
finishedThinking = true
process.stdout.write('\n\nResponse:\n========\n\n')
}
if (chunk.message.thinking) {
process.stdout.write(chunk.message.thinking)
} else if (chunk.message.content) {
process.stdout.write(chunk.message.content)
}
}
}
main()
+142
View File
@@ -0,0 +1,142 @@
import ollama, { Ollama } from 'ollama';
// Mock weather functions
function getTemperature(args: { city: string }): string {
const validCities = ['London', 'Paris', 'New York', 'Tokyo', 'Sydney'];
if (!validCities.includes(args.city)) {
return 'Unknown city';
}
return `${Math.floor(Math.random() * 36)} degrees Celsius`;
}
function getConditions(args: { city: string }): string {
const validCities = ['London', 'Paris', 'New York', 'Tokyo', 'Sydney'];
if (!validCities.includes(args.city)) {
return 'Unknown city';
}
const conditions = ['sunny', 'cloudy', 'rainy', 'snowy'];
return conditions[Math.floor(Math.random() * conditions.length)];
}
// Tool definitions
const getTemperatureTool = {
type: 'function',
function: {
name: 'getTemperature',
description: 'Get the temperature for a city in Celsius',
parameters: {
type: 'object',
required: ['city'],
properties: {
city: { type: 'string', description: 'The name of the city' }
}
}
}
};
const getConditionsTool = {
type: 'function',
function: {
name: 'getConditions',
description: 'Get the weather conditions for a city',
parameters: {
type: 'object',
required: ['city'],
properties: {
city: { type: 'string', description: 'The name of the city' }
}
}
}
};
async function run(model: string) {
const cities = ['London', 'Paris', 'New York', 'Tokyo', 'Sydney'];
const city = cities[Math.floor(Math.random() * cities.length)];
const city2 = cities[Math.floor(Math.random() * cities.length)];
const messages = [{
role: 'user',
content: `What is the temperature in ${city}? and what are the weather conditions in ${city2}?`
}];
console.log('----- Prompt:', messages[0].content, '\n');
const ollama = new Ollama();
const availableFunctions = {
getTemperature,
getConditions
};
const response = await ollama.chat({
model: model,
messages: messages,
tools: [getTemperatureTool, getConditionsTool],
stream: true,
think: true
});
for await (const chunk of response) {
if (chunk.message.thinking) {
process.stdout.write(chunk.message.thinking);
}
if (chunk.message.content) {
process.stdout.write(chunk.message.content);
}
if (chunk.message.tool_calls) {
for (const tool of chunk.message.tool_calls) {
const functionToCall = availableFunctions[tool.function.name];
if (functionToCall) {
console.log('\nCalling function:', tool.function.name, 'with arguments:', tool.function.arguments);
const output = functionToCall(tool.function.arguments);
console.log('> Function output:', output, '\n');
messages.push(chunk.message);
messages.push({
role: 'tool',
content: output.toString(),
tool_name: tool.function.name,
});
} else {
console.log('Function', tool.function.name, 'not found');
}
}
}
}
console.log('----- Sending result back to model \n');
if (messages.some(msg => msg.role === 'tool')) {
const finalResponse = await ollama.chat({
model: model,
messages: messages,
tools: [getTemperatureTool, getConditionsTool],
stream: true,
think: true
});
let doneThinking = false;
for await (const chunk of finalResponse) {
if (chunk.message.thinking) {
process.stdout.write(chunk.message.thinking);
}
if (chunk.message.content) {
if (!doneThinking) {
console.log('\n----- Final result:');
doneThinking = true;
}
process.stdout.write(chunk.message.content);
}
if (chunk.message.tool_calls) {
console.log('Model returned tool calls:');
console.log(chunk.message.tool_calls);
}
}
} else {
console.log('No tool calls returned');
}
}
run('qwen3').catch(console.error);
@@ -0,0 +1,771 @@
import type { SearchRequest, SearchResponse, FetchRequest, FetchResponse } from 'ollama'
interface Page {
url: string
title: string
text: string
lines: string[]
links: Record<number, string>
fetchedAt: Date
}
interface BrowserStateData {
pageStack: string[]
viewTokens: number
urlToPage: Record<string, Page>
}
interface WebSearchResult {
title?: string
url?: string
content: {
fullText: string
}
}
// Default number of tokens to show when calling displayPage
const DEFAULT_VIEW_TOKENS = 1024
// Capped tool content length
const CAPPED_TOOL_CONTENT_LEN = 8000
function capToolContent(text: string): string {
if (!text) {
return text
}
if (text.length <= CAPPED_TOOL_CONTENT_LEN) {
return text
}
if (CAPPED_TOOL_CONTENT_LEN <= 1) {
return text.substring(0, CAPPED_TOOL_CONTENT_LEN)
}
return text.substring(0, CAPPED_TOOL_CONTENT_LEN - 1) + '…'
}
/**
* The Browser tool provides web browsing capability.
* The model uses the tool by usually doing a search first and then choosing to either open a page,
* find a term in a page, or do another search.
*
* The tool optionally may open a URL directly - especially if one is passed in.
*
* Each action is saved into an append-only page stack to keep track of the history of the browsing session.
* Each Execute() for a tool returns the full current state of the browser.
*
* A new Browser object is created per request - the state is managed within the class.
*/
/**
* BrowserState manages the browsing session state
*/
export class BrowserState {
private data: BrowserStateData
constructor(initialState?: BrowserStateData) {
this.data = initialState || {
pageStack: [],
viewTokens: DEFAULT_VIEW_TOKENS,
urlToPage: {},
}
}
getData(): BrowserStateData {
return this.data
}
setData(data: BrowserStateData): void {
this.data = data
}
}
export class Browser {
public state: BrowserState
private searchClient?: {
search: (request: SearchRequest) => Promise<SearchResponse>
}
private fetchClient?: {
fetch: (request: FetchRequest) => Promise<FetchResponse>
}
constructor(
initialState?: BrowserStateData,
client?: {
search: (request: SearchRequest) => Promise<SearchResponse>
fetch: (request: FetchRequest) => Promise<FetchResponse>
},
) {
this.state = new BrowserState(initialState)
if (client) {
this.searchClient = client
this.fetchClient = client
}
}
setClients(client: {
search: (request: SearchRequest) => Promise<SearchResponse>
fetch: (request: FetchRequest) => Promise<FetchResponse>
}): void {
this.searchClient = client
this.fetchClient = client
}
getState(): BrowserStateData {
return this.state.getData()
}
protected savePage(page: Page): void {
const data = this.state.getData()
data.urlToPage[page.url] = page
data.pageStack.push(page.url)
this.state.setData(data)
}
protected getPageFromStack(url: string): Page {
const data = this.state.getData()
const page = data.urlToPage[url]
if (!page) {
throw new Error(`Page not found for url ${url}`)
}
return page
}
/**
* Calculates the end location for viewport based on token limits
*/
protected getEndLoc(
loc: number,
numLines: number,
totalLines: number,
lines: string[],
): number {
if (numLines <= 0) {
const txt = this.joinLinesWithNumbers(lines.slice(loc))
const data = this.state.getData()
if (txt.length > data.viewTokens) {
const maxCharsPerToken = 128
const upperBound = Math.min((data.viewTokens + 1) * maxCharsPerToken, txt.length)
const textToAnalyze = txt.substring(0, upperBound)
const approxTokens = textToAnalyze.length / 4
if (approxTokens > data.viewTokens) {
const endIdx = Math.min(data.viewTokens * 4, txt.length)
numLines = (txt.substring(0, endIdx).match(/\n/g) || []).length + 1
} else {
numLines = totalLines
}
} else {
numLines = totalLines
}
}
return Math.min(loc + numLines, totalLines)
}
protected joinLinesWithNumbers(lines: string[]): string {
let result = ''
let hadZeroLine = false
for (let i = 0; i < lines.length; i++) {
if (i === 0) {
result += 'L0:\n'
hadZeroLine = true
}
if (hadZeroLine) {
result += `L${i + 1}: ${lines[i]}\n`
} else {
result += `L${i}: ${lines[i]}\n`
}
}
return result
}
/**
* Processes markdown links and replaces them with the special format
* Returns the processed text and a map of link IDs to URLs
*/
protected processMarkdownLinks(text: string): {
processedText: string
links: Record<number, string>
} {
const links: Record<number, string> = {}
let linkID = 0
const multiLinePattern = /\[([^\]]+)\]\s*\n\s*\(([^)]+)\)/g
text = text.replace(multiLinePattern, (match) => {
let cleaned = match.replace(/\n/g, ' ')
cleaned = cleaned.replace(/\s+/g, ' ')
return cleaned
})
const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g
const processedText = text.replace(linkPattern, (match, linkText, linkURL) => {
const cleanLinkText = linkText.trim()
const cleanLinkURL = linkURL.trim()
let domain = cleanLinkURL
try {
const url = new URL(cleanLinkURL)
if (url.host) {
domain = url.host
domain = domain.replace(/^www\./, '')
}
} catch {
}
const formatted = `${linkID}${cleanLinkText}${domain}`
links[linkID] = cleanLinkURL
linkID++
return formatted
})
return { processedText, links }
}
/**
* Wraps text lines to a specified width
*/
protected wrapLines(text: string, width = 80): string[] {
if (width <= 0) {
width = 80
}
const lines = text.split('\n')
const wrapped: string[] = []
for (const line of lines) {
if (line === '') {
wrapped.push('')
} else if (line.length <= width) {
wrapped.push(line)
} else {
const words = line.split(/\s+/)
if (words.length === 0) {
wrapped.push(line)
continue
}
let currentLine = ''
for (const word of words) {
let testLine = currentLine
if (testLine !== '') {
testLine += ' '
}
testLine += word
if (testLine.length > width && currentLine !== '') {
wrapped.push(currentLine)
currentLine = word
} else {
if (currentLine !== '') {
currentLine += ' '
}
currentLine += word
}
}
if (currentLine !== '') {
wrapped.push(currentLine)
}
}
}
return wrapped
}
/**
* Formats and returns the page display for the model
*/
protected displayPage(
page: Page,
cursor: number,
loc: number,
numLines: number,
): string {
let totalLines = page.lines.length
if (totalLines === 0) {
page.lines = ['']
totalLines = 1
}
if (Number.isNaN(loc) || loc < 0) {
loc = 0
} else if (loc >= totalLines) {
loc = Math.max(0, totalLines - 1)
}
const endLoc = this.getEndLoc(loc, numLines, totalLines, page.lines)
let display = `[${cursor}] ${page.title}`
if (page.url) {
display += `(${page.url})\n`
} else {
display += '\n'
}
display += `**viewing lines [${loc} - ${endLoc - 1}] of ${totalLines - 1}**\n\n`
let hadZeroLine = false
for (let i = loc; i < endLoc; i++) {
if (i === 0) {
display += 'L0:\n'
hadZeroLine = true
}
if (hadZeroLine) {
display += `L${i + 1}: ${page.lines[i]}\n`
} else {
display += `L${i}: ${page.lines[i]}\n`
}
}
return display
}
/**
* Builds a search results page that contains all search results
*/
protected buildSearchResultsPageCollection(
query: string,
results: SearchResponse,
): Page {
const page: Page = {
url: `search_results_${query}`,
title: query,
text: '',
lines: [],
links: {},
fetchedAt: new Date(),
}
let textBuilder = ''
let linkIdx = 0
textBuilder += '\n'
textBuilder += 'URL: \n' // L1: URL: (empty for search)
textBuilder += '# Search Results\n' // L2: # Search Results
textBuilder += '\n' // L3: empty
for (const result of results.results as any[]) {
// Derive domain from URL if available
let domain = result.url || ''
try {
const url = new URL(domain)
if (url.host) {
domain = url.host.replace(/^www\./, '')
}
} catch {
// leave domain as-is if parsing fails
}
const title = result.title || `Result ${linkIdx}`
const linkFormat = `* 【${linkIdx}${title}${domain}`
textBuilder += linkFormat
const rawSnippet = result.content || ''
const capped = rawSnippet.length > 400 ? rawSnippet.substring(0, 400) + '…' : rawSnippet
const cleaned = capped
.replace(/\d{40,}/g, (m) => m.substring(0, 40) + '…')
.replace(/\s{3,}/g, ' ')
textBuilder += cleaned
textBuilder += '\n'
if (result.url) {
page.links[linkIdx] = result.url
}
linkIdx++
}
page.text = textBuilder
page.lines = this.wrapLines(page.text, 80)
return page
}
/**
* Builds a search results page for individual result
*/
protected buildSearchResultsPage(result: WebSearchResult, linkIdx: number): Page {
const page: Page = {
url: result.url || `result_${linkIdx}`,
title: result.title || `Result ${linkIdx}`,
text: '',
lines: [],
links: {},
fetchedAt: new Date(),
}
let textBuilder = ''
const linkFormat = `${linkIdx}${result.title || `Result ${linkIdx}`}`
textBuilder += linkFormat
textBuilder += '\n'
textBuilder += `URL: ${result.url || ''}\n`
const numChars = Math.min(result.content.fullText.length, 300)
textBuilder += result.content.fullText.substring(0, numChars)
textBuilder += '\n\n'
if (!result.content.fullText && result.url) {
page.links[linkIdx] = result.url
}
if (result.content.fullText) {
page.text = `URL: ${result.url || ''}\n${result.content.fullText}`
const { processedText, links } = this.processMarkdownLinks(page.text)
page.text = processedText
page.links = links
} else {
page.text = textBuilder
}
page.lines = this.wrapLines(page.text, 80)
return page
}
/**
* Creates a Page from fetch API results
*/
protected buildPageFromFetchResult(
requestedURL: string,
fetchResponse: FetchResponse,
): Page {
const page: Page = {
url: requestedURL,
title: requestedURL,
text: '',
lines: [],
links: {},
fetchedAt: new Date(),
}
if (fetchResponse.content) {
page.text = fetchResponse.content
}
if (fetchResponse.title) {
page.title = fetchResponse.title
}
if (fetchResponse.url) {
page.url = fetchResponse.url
}
if (!page.text) {
page.text = 'No content could be extracted from this page.'
} else {
page.text = `URL: ${page.url}\n${page.text}`
}
const { processedText, links } = this.processMarkdownLinks(page.text)
page.text = processedText
page.links = links
page.lines = this.wrapLines(page.text, 80)
return page
}
/**
* Builds a find results page
*/
protected buildFindResultsPage(pattern: string, page: Page): Page {
const findPage: Page = {
url: `find_results_${pattern}`,
title: `Find results for text: \`${pattern}\` in \`${page.title}\``,
text: '',
lines: [],
links: {},
fetchedAt: new Date(),
}
let textBuilder = ''
let matchIdx = 0
const maxResults = 50
const numShowLines = 4
const patternLower = pattern.toLowerCase()
const resultChunks: string[] = []
let lineIdx = 0
while (lineIdx < page.lines.length) {
const line = page.lines[lineIdx]
const lineLower = line.toLowerCase()
if (!lineLower.includes(patternLower)) {
lineIdx++
continue
}
const endLine = Math.min(lineIdx + numShowLines, page.lines.length)
let snippetBuilder = ''
for (let j = lineIdx; j < endLine; j++) {
snippetBuilder += page.lines[j]
if (j < endLine - 1) {
snippetBuilder += '\n'
}
}
const snippet = snippetBuilder
const linkFormat = `${matchIdx}†match at L${lineIdx}`
const resultChunk = `${linkFormat}\n${snippet}`
resultChunks.push(resultChunk)
if (resultChunks.length >= maxResults) {
break
}
matchIdx++
lineIdx += numShowLines
}
if (resultChunks.length > 0) {
textBuilder = resultChunks.join('\n\n')
}
if (matchIdx === 0) {
findPage.text = `No \`find\` results for pattern: \`${pattern}\``
} else {
findPage.text = textBuilder
}
findPage.lines = this.wrapLines(findPage.text, 80)
return findPage
}
async search(args: {
query: string
topn?: number
}): Promise<{ state: BrowserStateData; pageText: string }> {
const { query, topn = 5 } = args
if (!this.searchClient) {
throw new Error('Search client not provided')
}
const searchArgs: SearchRequest = {
query,
max_results: topn,
}
const result = await this.searchClient.search(searchArgs)
const searchResultsPage = this.buildSearchResultsPageCollection(query, result)
this.savePage(searchResultsPage)
const cursor = this.getState().pageStack.length - 1
for (let i = 0; i < result.results.length; i++) {
const searchResult = result.results[i] as any
const webSearchResult: WebSearchResult = {
title: searchResult.title || 'Search Result',
url: searchResult.url || `result_${i}`,
content: {
fullText: searchResult.content || '',
},
}
const resultPage = this.buildSearchResultsPage(webSearchResult, i + 1)
const data = this.getState()
data.urlToPage[resultPage.url] = resultPage
this.state.setData(data)
}
const pageText = this.displayPage(searchResultsPage, cursor, 0, -1)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
async open(args: {
id?: string | number
cursor?: number
loc?: number
num_lines?: number
}): Promise<{ state: BrowserStateData; pageText: string }> {
if (!this.fetchClient) {
throw new Error('fetch client not provided')
}
let { cursor = -1 } = args
const loc = args.loc ?? 0
const num_lines = args.num_lines ?? -1
let page: Page | undefined
const state = this.getState()
if (typeof args.id === 'string') {
const url = args.id
if (state.urlToPage[url]) {
this.savePage(state.urlToPage[url])
cursor = this.getState().pageStack.length - 1
const pageText = this.displayPage(state.urlToPage[url], cursor, loc, num_lines)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
const fetchResponse = await this.fetchClient.fetch({ url })
const newPage = this.buildPageFromFetchResult(url, fetchResponse)
this.savePage(newPage)
cursor = this.getState().pageStack.length - 1
const pageText = this.displayPage(newPage, cursor, loc, num_lines)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
if (cursor >= 0) {
if (cursor >= state.pageStack.length) {
cursor = Math.max(0, state.pageStack.length - 1)
}
page = this.getPageFromStack(state.pageStack[cursor])
} else {
if (state.pageStack.length !== 0) {
const pageURL = state.pageStack[state.pageStack.length - 1]
page = this.getPageFromStack(pageURL)
}
}
if (typeof args.id === 'number') {
if (!page) {
throw new Error('No current page to resolve link from')
}
const idInt = args.id
const pageURL = page.links[idInt]
if (!pageURL) {
const errorPage: Page = {
url: `invalid_link_${idInt}`,
title: `No link with id ${idInt} on \`${page.title}\``,
text: '',
lines: [],
links: {},
fetchedAt: new Date(),
}
const availableIds = Object.keys(page.links)
.map((k) => Number(k))
.sort((a, b) => a - b)
const availableList = availableIds.length > 0 ? availableIds.join(', ') : '(none)'
errorPage.text = [
`Requested link id: ${idInt}`,
`Current page: ${page.title}`,
`Available link ids on this page: ${availableList}`,
'',
'Tips:',
'- To scroll this page, call browser_open with { loc, num_lines } (no id).',
'- To open a result from a search results page, pass the correct { cursor, id }.',
].join('\n')
errorPage.lines = this.wrapLines(errorPage.text, 80)
this.savePage(errorPage)
cursor = this.getState().pageStack.length - 1
const pageText = this.displayPage(errorPage, cursor, 0, -1)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
let newPage = state.urlToPage[pageURL]
if (!newPage) {
console.log('[browser_open] fetching URL from link id:', pageURL)
let fetchResponse: FetchResponse
try {
fetchResponse = await this.fetchClient.fetch({ url: pageURL })
} catch (error) {
// Create an error page when fetch fails
const errorPage: Page = {
url: pageURL,
title: `Failed to fetch: ${pageURL}`,
text: `This tool result wasn't accessible. Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
lines: [`This tool result wasn't accessible. Error: ${error instanceof Error ? error.message : 'Unknown error'}`],
links: {},
fetchedAt: new Date(),
}
this.savePage(errorPage)
cursor = this.getState().pageStack.length - 1
const pageText = this.displayPage(errorPage, cursor, 0, -1)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
newPage = this.buildPageFromFetchResult(pageURL, fetchResponse)
}
this.savePage(newPage)
cursor = this.getState().pageStack.length - 1
const pageText = this.displayPage(newPage, cursor, loc, num_lines)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
if (!page) {
throw new Error('No current page to display')
}
const currentState = this.getState()
currentState.pageStack.push(page.url)
this.state.setData(currentState)
cursor = currentState.pageStack.length - 1
const pageText = this.displayPage(page, cursor, loc, num_lines)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
async find(args: {
pattern: string
cursor?: number
}): Promise<{ state: BrowserStateData; pageText: string }> {
const { pattern } = args
let { cursor = -1 } = args
let page: Page
const state = this.getState()
if (cursor === -1) {
if (state.pageStack.length === 0) {
throw new Error('No pages to search in')
}
page = this.getPageFromStack(state.pageStack[state.pageStack.length - 1])
cursor = state.pageStack.length - 1
} else {
if (cursor < 0 || cursor >= state.pageStack.length) {
cursor = Math.max(0, Math.min(cursor, state.pageStack.length - 1))
}
page = this.getPageFromStack(state.pageStack[cursor])
}
const findPage = this.buildFindResultsPage(pattern, page)
this.savePage(findPage)
const newCursor = this.getState().pageStack.length - 1
const pageText = this.displayPage(findPage, newCursor, 0, -1)
return { state: this.getState(), pageText: capToolContent(pageText) }
}
}
+172
View File
@@ -0,0 +1,172 @@
import ollama, { Ollama } from 'ollama'
import type { Message } from 'ollama'
import { Browser } from './gpt-oss-browser-tools-helpers'
async function main() {
if (!process.env.OLLAMA_API_KEY) {
throw new Error('Set OLLAMA_API_KEY to use browser tools')
}
const client = new Ollama({
headers: process.env.OLLAMA_API_KEY
? { Authorization: `Bearer ${process.env.OLLAMA_API_KEY}` }
: undefined,
})
const browser = new Browser(undefined, {
search: (request) => client.webSearch(request as any),
fetch: (request) => client.webFetch(request as any),
})
// Tool schemas for the model
const browserSearchTool = {
type: 'function',
function: {
name: 'websearch',
description: 'Performs a web search for the given query.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'The search query string.' },
topn: { type: 'number', description: 'Max results to return (default 5).' },
},
required: ['query'],
},
},
}
const browserOpenTool = {
type: 'function',
function: {
name: 'browser_open',
description: 'Open a search result or URL, or scroll the current page.',
parameters: {
type: 'object',
properties: {
id: {
description: 'Link id (number) from the results page, or a URL string to open',
anyOf: [{ type: 'number' }, { type: 'string' }],
},
cursor: { type: 'number', description: 'Page index in the stack to operate on' },
loc: { type: 'number', description: 'Start line to view from' },
num_lines: { type: 'number', description: 'Number of lines to display (-1 for auto)' },
},
},
},
}
const browserFindTool = {
type: 'function',
function: {
name: 'browser_find',
description: 'Find a pattern within the currently open page.',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Text to search for in the page' },
cursor: { type: 'number', description: 'Page index in the stack to search' },
},
required: ['pattern'],
},
},
}
const availableTools = {
websearch: async (args: { query: string; topn?: number }) => {
const result = await browser.search(args)
return result.pageText
},
browser_open: async (args: {
id?: string | number
cursor?: number
loc?: number
num_lines?: number
}) => {
const result = await browser.open(args)
return result.pageText
},
browser_find: async (args: { pattern: string; cursor?: number }) => {
const result = await browser.find(args)
return result.pageText
},
}
const messages: Message[] = [
{
role: 'user',
content: 'what is ollama new engine?',
},
]
console.log('Prompt:', messages.find((m) => m.role === 'user')?.content, '\n')
while (true) {
const response = await client.chat({
model: 'gpt-oss',
messages: messages,
tools: [browserSearchTool, browserOpenTool, browserFindTool],
stream: true,
think: true,
})
let hadToolCalls = false
let startedThinking = false
let finishedThinking = false
let content = ''
let thinking = ''
for await (const chunk of response) {
if (chunk.message.thinking && !startedThinking) {
startedThinking = true
process.stdout.write('Thinking:\n========\n\n')
} else if (chunk.message.content && startedThinking && !finishedThinking) {
finishedThinking = true
process.stdout.write('\n\nResponse:\n========\n\n')
}
if (chunk.message.thinking) {
thinking += chunk.message.thinking
process.stdout.write(chunk.message.thinking)
}
if (chunk.message.content) {
content += chunk.message.content
process.stdout.write(chunk.message.content)
}
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
messages.push({
role: 'assistant',
content: content,
thinking: thinking,
})
hadToolCalls = true
for (const toolCall of chunk.message.tool_calls) {
const functionToCall =
availableTools[toolCall.function.name]
if (functionToCall) {
const args = toolCall.function.arguments as any
console.log('\nCalling function:', toolCall.function.name, 'with arguments:', args)
let output
try {
output = await functionToCall(args)
} catch (error) {
output = { error: error instanceof Error ? error.message : 'Unknown error' }
}
messages.push(chunk.message)
messages.push({
role: 'tool',
content: JSON.stringify(output),
tool_name: toolCall.function.name,
})
}
}
}
}
if (!hadToolCalls) {
break
}
}
}
main().catch(console.error)
+135
View File
@@ -0,0 +1,135 @@
import {
Ollama,
type Message,
type WebSearchResponse,
type WebFetchResponse,
} from 'ollama'
async function main() {
// Set enviornment variable OLLAMA_API_KEY=<YOUR>.<KEY>
// or set the header manually
// const client = new Ollama({
// headers: { Authorization: `Bearer ${process.env.OLLAMA_API_KEY}` },
// })
const client = new Ollama()
// Tool schemas
const webSearchTool = {
type: 'function',
function: {
name: 'webSearch',
description: 'Performs a web search for the given query.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query string.' },
max_results: {
type: 'number',
description: 'The maximum number of results to return per query (default 3).',
},
},
required: ['query'],
},
},
}
const webFetchTool = {
type: 'function',
function: {
name: 'webFetch',
description: 'Fetches a single page by URL.',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'A single URL to fetch.' },
},
required: ['url'],
},
},
}
const availableTools = {
webSearch: async (args: {
query: string
max_results?: number
}): Promise<WebSearchResponse> => {
const res = await client.webSearch(args)
return res as WebSearchResponse
},
webFetch: async (args: { url: string }): Promise<WebFetchResponse> => {
const res = await client.webFetch(args)
return res as WebFetchResponse
},
}
const query = 'What is Ollama?'
console.log('Prompt:', query, '\n')
const messages: Message[] = [
{
role: 'user',
content: query,
},
]
while (true) {
const response = await client.chat({
model: 'qwen3',
messages: messages,
tools: [webSearchTool, webFetchTool],
stream: true,
think: true,
})
let hadToolCalls = false
var content = ''
var thinking = ''
for await (const chunk of response) {
if (chunk.message.thinking) {
thinking += chunk.message.thinking
}
if (chunk.message.content) {
content += chunk.message.content
process.stdout.write(chunk.message.content)
}
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
hadToolCalls = true
messages.push({
role: 'assistant',
content: content,
thinking: thinking,
tool_calls: chunk.message.tool_calls,
})
// Execute tools and append tool results
for (const toolCall of chunk.message.tool_calls) {
const functionToCall = availableTools[toolCall.function.name]
if (functionToCall) {
const args = toolCall.function.arguments as any
console.log(
'\nCalling function:',
toolCall.function.name,
'with arguments:',
args,
)
const output = await functionToCall(args)
console.log('Function result:', JSON.stringify(output).slice(0, 200), '\n')
messages.push(chunk.message)
messages.push({
role: 'tool',
content: JSON.stringify(output).slice(0, 2000 * 4), // cap at ~2000 tokens
tool_name: toolCall.function.name,
})
}
}
}
}
if (!hadToolCalls) {
process.stdout.write('\n')
break
}
}
}
main().catch(console.error)
+52 -1
View File
@@ -24,7 +24,13 @@ import type {
ShowRequest,
ShowResponse,
StatusResponse,
WebSearchRequest,
WebSearchResponse,
WebFetchRequest,
WebFetchResponse,
VersionResponse,
} from './interfaces.js'
import { defaultHost } from './constant.js'
export class Ollama {
protected readonly config: Config
@@ -38,12 +44,14 @@ export class Ollama {
}
if (!config?.proxy) {
this.config.host = utils.formatHost(config?.host ?? 'http://127.0.0.1:11434')
this.config.host = utils.formatHost(config?.host ?? defaultHost)
}
this.fetch = config?.fetch ?? fetch
}
// Abort any ongoing streamed requests to Ollama
public abort() {
for (const request of this.ongoingStreamedRequests) {
@@ -319,9 +327,52 @@ async encodeImage(image: Uint8Array | string): Promise<string> {
})
return (await response.json()) as ListResponse
}
/**
* Returns the Ollama server version.
* @returns {Promise<VersionResponse>} - The server version object.
*/
async version(): Promise<VersionResponse> {
const response = await utils.get(this.fetch, `${this.config.host}/api/version`, {
headers: this.config.headers,
})
return (await response.json()) as VersionResponse
}
/**
* Performs web search using the Ollama web search API
* @param request {WebSearchRequest} - The search request containing query and options
* @returns {Promise<WebSearchResponse>} - The search results
* @throws {Error} - If the request is invalid or the server returns an error
*/
async webSearch(request: WebSearchRequest): Promise<WebSearchResponse> {
if (!request.query || request.query.length === 0) {
throw new Error('Query is required')
}
const response = await utils.post(this.fetch, `https://ollama.com/api/web_search`, { ...request }, {
headers: this.config.headers
})
return (await response.json()) as WebSearchResponse
}
/**
* Fetches a single page using the Ollama web fetch API
* @param request {WebFetchRequest} - The fetch request containing a URL
* @returns {Promise<WebFetchResponse>} - The fetch result
* @throws {Error} - If the request is invalid or the server returns an error
*/
async webFetch(request: WebFetchRequest): Promise<WebFetchResponse> {
if (!request.url || request.url.length === 0) {
throw new Error('URL is required')
}
const response = await utils.post(this.fetch, `https://ollama.com/api/web_fetch`, { ...request }, { headers: this.config.headers })
return (await response.json()) as WebFetchResponse
}
}
export default new Ollama()
// export all types from the main entry point so that packages importing types dont need to specify paths
export * from './interfaces.js'
export type { AbortableAsyncIterator }
+2
View File
@@ -0,0 +1,2 @@
export const defaultPort = '11434';
export const defaultHost = `http://127.0.0.1:${defaultPort}`;
+2 -2
View File
@@ -1,7 +1,7 @@
import { AbortableAsyncIterator } from './utils.js'
import fs, { promises } from 'fs'
import { resolve } from 'path'
import fs, { promises } from 'node:fs'
import { resolve } from 'node:path'
import { Ollama as OllamaBrowser } from './browser.js'
import type { CreateRequest, ProgressResponse } from './interfaces.js'
+80 -11
View File
@@ -30,6 +30,7 @@ export interface Options {
num_predict: number
top_k: number
top_p: number
min_p: number
tfs_z: number
typical_p: number
repeat_last_n: number
@@ -56,6 +57,14 @@ export interface GenerateRequest {
format?: string | object
images?: Uint8Array[] | string[]
keep_alive?: string | number // a number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc)
think?: boolean | 'high' | 'medium' | 'low'
logprobs?: boolean
top_logprobs?: number
// Experimental image generation parameters
width?: number
height?: number
steps?: number
options?: Partial<Options>
}
@@ -63,8 +72,10 @@ export interface GenerateRequest {
export interface Message {
role: string
content: string
thinking?: string
images?: Uint8Array[] | string[]
tool_calls?: ToolCall[]
tool_name?: string
}
export interface ToolCall {
@@ -79,16 +90,20 @@ export interface ToolCall {
export interface Tool {
type: string;
function: {
name: string;
description: string;
parameters: {
type: string;
required: string[];
properties: {
name?: string;
description?: string;
type?: string;
parameters?: {
type?: string;
$defs?: any;
items?: any;
required?: string[];
properties?: {
[key: string]: {
type: string;
description: string;
enum?: string[];
type?: string | string[];
items?: any;
description?: string;
enum?: any[];
};
};
};
@@ -102,6 +117,9 @@ export interface ChatRequest {
format?: string | object
keep_alive?: string | number // a number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc)
tools?: Tool[]
think?: boolean | 'high' | 'medium' | 'low'
logprobs?: boolean
top_logprobs?: number
options?: Partial<Options>
}
@@ -152,6 +170,7 @@ export interface EmbedRequest {
input: string | string[]
truncate?: boolean
keep_alive?: string | number // a number (seconds) or a string with a duration unit suffix ("300ms", "1.5h", "2h45m", etc)
dimensions?: number
options?: Partial<Options>
}
@@ -165,11 +184,20 @@ export interface EmbeddingsRequest {
}
// response types
export interface TokenLogprob {
token: string
logprob: number
}
export interface Logprob extends TokenLogprob {
top_logprobs?: TokenLogprob[]
}
export interface GenerateResponse {
model: string
created_at: Date
response: string
response?: string
thinking?: string
done: boolean
done_reason: string
context: number[]
@@ -179,6 +207,12 @@ export interface GenerateResponse {
prompt_eval_duration: number
eval_count: number
eval_duration: number
logprobs?: Logprob[]
// Image generation response fields
image?: string // Base64-encoded generated image data
completed?: number // Number of completed steps (for streaming progress)
total?: number // Total number of steps (for streaming progress)
}
export interface ChatResponse {
@@ -193,11 +227,15 @@ export interface ChatResponse {
prompt_eval_duration: number
eval_count: number
eval_duration: number
logprobs?: Logprob[]
}
export interface EmbedResponse {
model: string
embeddings: number[][]
total_duration: number
load_duration: number
prompt_eval_count: number
}
export interface EmbeddingsResponse {
@@ -214,6 +252,7 @@ export interface ProgressResponse {
export interface ModelResponse {
name: string
modified_at: Date
model: string
size: number
digest: string
details: ModelDetails
@@ -239,10 +278,15 @@ export interface ShowResponse {
details: ModelDetails
messages: Message[]
modified_at: Date
model_info: Map<string, any>
model_info: Map<string, any>,
capabilities: string[],
projector_info?: Map<string, any>
}
export interface VersionResponse {
version: string
}
export interface ListResponse {
models: ModelResponse[]
}
@@ -254,3 +298,28 @@ export interface ErrorResponse {
export interface StatusResponse {
status: string
}
export interface WebSearchRequest {
query: string
maxResults?: number
}
export interface WebSearchResult {
content: string
}
export interface WebSearchResponse {
results: WebSearchResult[]
}
// Fetch types
export interface WebFetchRequest {
url: string
}
export interface WebFetchResponse {
title: string
url: string
content: string
links: string[]
}
+104 -17
View File
@@ -1,5 +1,6 @@
import { version } from './version.js'
import { defaultHost, defaultPort } from './constant.js'
import type { ErrorResponse, Fetch } from './interfaces.js'
import { version } from './version.js'
/**
* An error class for response errors.
@@ -27,7 +28,11 @@ export class AbortableAsyncIterator<T extends object> {
private readonly itr: AsyncGenerator<T | ErrorResponse>
private readonly doneCallback: () => void
constructor(abortController: AbortController, itr: AsyncGenerator<T | ErrorResponse>, doneCallback: () => void) {
constructor(
abortController: AbortController,
itr: AsyncGenerator<T | ErrorResponse>,
doneCallback: () => void,
) {
this.abortController = abortController
this.itr = itr
this.doneCallback = doneCallback
@@ -92,13 +97,53 @@ const checkOk = async (response: Response): Promise<void> => {
*/
function getPlatform(): string {
if (typeof window !== 'undefined' && window.navigator) {
return `${window.navigator.platform.toLowerCase()} Browser/${navigator.userAgent};`
// Need type assertion here since TypeScript doesn't know about userAgentData
const nav = navigator as any
if ('userAgentData' in nav && nav.userAgentData?.platform) {
return `${nav.userAgentData.platform.toLowerCase()} Browser/${navigator.userAgent};`
}
if (navigator.platform) {
return `${navigator.platform.toLowerCase()} Browser/${navigator.userAgent};`
}
return `unknown Browser/${navigator.userAgent};`
} else if (typeof process !== 'undefined') {
return `${process.arch} ${process.platform} Node.js/${process.version}`
}
return '' // unknown
}
/**
* Normalizes headers into a plain object format.
* This function handles various types of HeaderInit objects such as Headers, arrays of key-value pairs,
* and plain objects, converting them all into an object structure.
*
* @param {HeadersInit|undefined} headers - The headers to normalize. Can be one of the following:
* - A `Headers` object from the Fetch API.
* - A plain object with key-value pairs representing headers.
* - An array of key-value pairs representing headers.
* @returns {Record<string,string>} - A plain object representing the normalized headers.
*/
function normalizeHeaders(headers?: HeadersInit | undefined): Record<string, string> {
if (headers instanceof Headers) {
// If headers are an instance of Headers, convert it to an object
const obj: Record<string, string> = {}
headers.forEach((value, key) => {
obj[key] = value
})
return obj
} else if (Array.isArray(headers)) {
// If headers are in array format, convert them to an object
return Object.fromEntries(headers)
} else {
// Otherwise assume it's already a plain object
return headers || {}
}
}
const readEnvVar = (obj: object, key: string): string | undefined => {
return obj[key]
}
/**
* A wrapper around fetch that adds default headers.
* @param fetch {Fetch} - The fetch function to use
@@ -117,18 +162,42 @@ const fetchWithHeaders = async (
'User-Agent': `ollama-js/${version} (${getPlatform()})`,
} as HeadersInit
if (!options.headers) {
options.headers = {}
// Normalizes headers into a plain object format.
options.headers = normalizeHeaders(options.headers)
// Automatically add the API key to the headers if the URL is https://ollama.com
try {
const parsed = new URL(url)
if (parsed.protocol === 'https:' && parsed.hostname === 'ollama.com') {
const apiKey =
typeof process === 'object' &&
process !== null &&
typeof process.env === 'object' &&
process.env !== null
? readEnvVar(process.env, 'OLLAMA_API_KEY')
: undefined
const authorization =
options.headers['authorization'] || options.headers['Authorization']
if (!authorization && apiKey) {
options.headers['Authorization'] = `Bearer ${apiKey}`
}
}
} catch (error) {
console.error('error parsing url', error)
}
// Filter out default headers from custom headers
const customHeaders = Object.fromEntries(
Object.entries(options.headers).filter(([key]) => !Object.keys(defaultHeaders).some(defaultKey => defaultKey.toLowerCase() === key.toLowerCase()))
Object.entries(options.headers).filter(
([key]) =>
!Object.keys(defaultHeaders).some(
(defaultKey) => defaultKey.toLowerCase() === key.toLowerCase(),
),
),
)
options.headers = {
...defaultHeaders,
...customHeaders
...customHeaders,
}
return fetch(url, options)
@@ -140,9 +209,13 @@ const fetchWithHeaders = async (
* @param host {string} - The host to fetch
* @returns {Promise<Response>} - The fetch response
*/
export const get = async (fetch: Fetch, host: string, options?: { headers?: HeadersInit }): Promise<Response> => {
export const get = async (
fetch: Fetch,
host: string,
options?: { headers?: HeadersInit },
): Promise<Response> => {
const response = await fetchWithHeaders(fetch, host, {
headers: options?.headers
headers: options?.headers,
})
await checkOk(response)
@@ -176,7 +249,7 @@ export const post = async (
fetch: Fetch,
host: string,
data?: Record<string, unknown> | BodyInit,
options?: { signal?: AbortSignal, headers?: HeadersInit },
options?: { signal?: AbortSignal; headers?: HeadersInit },
): Promise<Response> => {
const isRecord = (input: any): input is Record<string, unknown> => {
return input !== null && typeof input === 'object' && !Array.isArray(input)
@@ -188,7 +261,7 @@ export const post = async (
method: 'POST',
body: formattedData,
signal: options?.signal,
headers: options?.headers
headers: options?.headers,
})
await checkOk(response)
@@ -211,7 +284,7 @@ export const del = async (
const response = await fetchWithHeaders(fetch, host, {
method: 'DELETE',
body: JSON.stringify(data),
headers: options?.headers
headers: options?.headers,
})
await checkOk(response)
@@ -235,10 +308,11 @@ export const parseJSON = async function* <T = unknown>(
const { done, value: chunk } = await reader.read()
if (done) {
reader.releaseLock()
break
}
buffer += decoder.decode(chunk)
buffer += decoder.decode(chunk, { stream: true })
const parts = buffer.split('\n')
@@ -253,6 +327,9 @@ export const parseJSON = async function* <T = unknown>(
}
}
// Flush any remaining bytes from incomplete multibyte sequences
buffer += decoder.decode()
for (const part of buffer.split('\n').filter((p) => p !== '')) {
try {
yield JSON.parse(part)
@@ -268,7 +345,7 @@ export const parseJSON = async function* <T = unknown>(
*/
export const formatHost = (host: string): string => {
if (!host) {
return 'http://127.0.0.1:11434'
return defaultHost
}
let isExplicitProtocol = host.includes('://')
@@ -288,14 +365,24 @@ export const formatHost = (host: string): string => {
let port = url.port
if (!port) {
if (!isExplicitProtocol) {
port = '11434'
port = defaultPort
} else {
// Assign default ports based on the protocol
port = url.protocol === 'https:' ? '443' : '80'
}
}
let formattedHost = `${url.protocol}//${url.hostname}:${port}${url.pathname}`
// Build basic auth part if present
let auth = ''
if (url.username) {
auth = url.username
if (url.password) {
auth += `:${url.password}`
}
auth += '@'
}
let formattedHost = `${url.protocol}//${auth}${url.hostname}:${port}${url.pathname}`
// remove trailing slashes
if (formattedHost.endsWith('/')) {
formattedHost = formattedHost.slice(0, -1)
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi } from 'vitest'
import { Ollama } from '../src/browser'
import type { ChatResponse, GenerateResponse } from '../src/interfaces'
import type { AbortableAsyncIterator } from '../src/browser'
describe('AbortableAsyncIterator type export', () => {
it('should be importable from browser module', () => {
const typeCheck = (_: AbortableAsyncIterator<ChatResponse> | null) => {}
typeCheck(null)
expect(true).toBe(true)
})
})
describe('Ollama logprob request fields', () => {
it('forwards logprob settings in generate requests', async () => {
const client = new Ollama()
const spy = vi
.spyOn(client as any, 'processStreamableRequest')
.mockResolvedValue({} as GenerateResponse)
await client.generate({
model: 'dummy',
prompt: 'Hello',
logprobs: true,
top_logprobs: 5,
})
expect(spy).toHaveBeenCalledWith(
'generate',
expect.objectContaining({
logprobs: true,
top_logprobs: 5,
}),
)
})
it('forwards logprob settings in chat requests', async () => {
const client = new Ollama()
const spy = vi
.spyOn(client as any, 'processStreamableRequest')
.mockResolvedValue({} as ChatResponse)
await client.chat({
model: 'dummy',
messages: [{ role: 'user', content: 'hi' }],
logprobs: true,
top_logprobs: 3,
})
expect(spy).toHaveBeenCalledWith(
'chat',
expect.objectContaining({
logprobs: true,
top_logprobs: 3,
}),
)
})
})
describe('Ollama image generation request fields', () => {
it('forwards image generation parameters in generate requests', async () => {
const client = new Ollama()
const spy = vi
.spyOn(client as any, 'processStreamableRequest')
.mockResolvedValue({} as GenerateResponse)
await client.generate({
model: 'dummy-image',
prompt: 'a sunset over mountains',
width: 1024,
height: 768,
steps: 20,
})
expect(spy).toHaveBeenCalledWith(
'generate',
expect.objectContaining({
model: 'dummy-image',
prompt: 'a sunset over mountains',
width: 1024,
height: 768,
steps: 20,
}),
)
})
it('handles image generation response with image field', async () => {
const mockResponse: GenerateResponse = {
model: 'dummy-image',
created_at: new Date(),
done: true,
done_reason: 'stop',
context: [],
total_duration: 1000,
load_duration: 100,
prompt_eval_count: 10,
prompt_eval_duration: 50,
eval_count: 0,
eval_duration: 0,
image: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
}
const client = new Ollama()
vi.spyOn(client as any, 'processStreamableRequest').mockResolvedValue(mockResponse)
const response = await client.generate({
model: 'dummy-image',
prompt: 'a sunset',
})
expect(response.image).toBeDefined()
expect(response.done).toBe(true)
})
it('handles streaming progress fields for image generation', async () => {
const mockResponse: GenerateResponse = {
model: 'dummy-image',
created_at: new Date(),
done: false,
done_reason: '',
context: [],
total_duration: 0,
load_duration: 0,
prompt_eval_count: 0,
prompt_eval_duration: 0,
eval_count: 0,
eval_duration: 0,
completed: 5,
total: 20,
}
const client = new Ollama()
vi.spyOn(client as any, 'processStreamableRequest').mockResolvedValue(mockResponse)
const response = await client.generate({
model: 'dummy-image',
prompt: 'a sunset',
})
expect(response.completed).toBe(5)
expect(response.total).toBe(20)
expect(response.done).toBe(false)
})
})
+32 -2
View File
@@ -1,9 +1,10 @@
import { describe, it, expect } from 'vitest'
import { describe, expect, it } from 'vitest'
import { defaultHost } from '../src/constant'
import { formatHost } from '../src/utils'
describe('formatHost Function Tests', () => {
it('should return default URL for empty string', () => {
expect(formatHost('')).toBe('http://127.0.0.1:11434')
expect(formatHost('')).toBe(defaultHost)
})
it('should parse plain IP address', () => {
@@ -61,4 +62,33 @@ describe('formatHost Function Tests', () => {
it('should handle trailing slash with only a port', () => {
expect(formatHost(':56789/')).toBe('http://127.0.0.1:56789')
})
// Basic Auth Tests
it('should preserve username in URL', () => {
expect(formatHost('http://user@localhost:1234')).toBe('http://user@localhost:1234')
})
it('should preserve username and password in URL', () => {
expect(formatHost('http://user:pass@localhost:5678')).toBe('http://user:pass@localhost:5678')
})
it('should preserve username with default port', () => {
expect(formatHost('http://user@localhost')).toBe('http://user@localhost:80')
})
it('should preserve username and password with default port', () => {
expect(formatHost('http://user:pass@localhost')).toBe('http://user:pass@localhost:80')
})
it('should preserve basic auth with https', () => {
expect(formatHost('https://user:secret@secure.com')).toBe('https://user:secret@secure.com:443')
})
it('should preserve basic auth with domain and custom port', () => {
expect(formatHost('http://admin:1234@example.com:8080')).toBe('http://admin:1234@example.com:8080')
})
it('should preserve basic auth and remove trailing slash', () => {
expect(formatHost('http://john:doe@site.com:3000/')).toBe('http://john:doe@site.com:3000')
})
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { get, parseJSON } from '../src/utils'
describe('get Function Header Tests', () => {
const mockFetch = vi.fn();
const mockResponse = new Response(null, { status: 200 });
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue(mockResponse);
});
const defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': expect.stringMatching(/ollama-js\/.*/)
};
it('should use default headers when no headers provided', async () => {
await get(mockFetch, 'http://example.com');
expect(mockFetch).toHaveBeenCalledWith('http://example.com', {
headers: expect.objectContaining(defaultHeaders)
});
});
it('should handle Headers instance', async () => {
const customHeaders = new Headers({
'Authorization': 'Bearer token',
'X-Custom': 'value'
});
await get(mockFetch, 'http://example.com', { headers: customHeaders });
expect(mockFetch).toHaveBeenCalledWith('http://example.com', {
headers: expect.objectContaining({
...defaultHeaders,
'authorization': 'Bearer token',
'x-custom': 'value'
})
});
});
it('should handle plain object headers', async () => {
const customHeaders = {
'Authorization': 'Bearer token',
'X-Custom': 'value'
};
await get(mockFetch, 'http://example.com', { headers: customHeaders });
expect(mockFetch).toHaveBeenCalledWith('http://example.com', {
headers: expect.objectContaining({
...defaultHeaders,
'Authorization': 'Bearer token',
'X-Custom': 'value'
})
});
});
it('should not allow custom headers to override default User-Agent', async () => {
const customHeaders = {
'User-Agent': 'custom-agent'
};
await get(mockFetch, 'http://example.com', { headers: customHeaders });
expect(mockFetch).toHaveBeenCalledWith('http://example.com', {
headers: expect.objectContaining({
'User-Agent': expect.stringMatching(/ollama-js\/.*/)
})
});
});
it('should handle empty headers object', async () => {
await get(mockFetch, 'http://example.com', { headers: {} });
expect(mockFetch).toHaveBeenCalledWith('http://example.com', {
headers: expect.objectContaining(defaultHeaders)
});
});
});
describe('parseJSON UTF-8 multibyte character handling', () => {
it('should correctly decode multibyte UTF-8 characters split across chunk boundaries', async () => {
const encoder = new TextEncoder()
// Create chunks where the 'ь' character (UTF-8: 0xD1 0x8C) is split
const chunks = [
new Uint8Array([...encoder.encode('{"text":"использоват'), 0xd1]),
new Uint8Array([0x8c, ...encoder.encode('"}\n')]),
]
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk)
}
controller.close()
},
})
const itr = parseJSON<{ text: string }>(stream)
const { value } = await itr.next()
expect(value?.text).toBe('использовать')
})
});