Compare commits

...

25 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
15 changed files with 1596 additions and 34 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
-1
View File
@@ -2,7 +2,6 @@ node_modules
build
.docs
.coverage
node_modules
package-lock.json
yarn.lock
.vscode
+104
View File
@@ -46,6 +46,73 @@ for await (const part of response) {
}
```
## 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'
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
The Ollama JavaScript library's API is designed around the [Ollama REST API](https://github.com/jmorganca/ollama/blob/main/docs/api.md)
@@ -67,6 +134,8 @@ ollama.chat(request)
- `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.
@@ -90,7 +159,12 @@ ollama.generate(request)
- `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>`
@@ -195,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
@@ -203,6 +299,14 @@ ollama.ps()
- Returns: `<ListResponse>`
### version
```javascript
ollama.version()
```
- Returns: `<VersionResponse>`
### abort
```javascript
+6
View File
@@ -8,3 +8,9 @@ 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)
@@ -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
})
+1 -1
View File
@@ -1,4 +1,4 @@
import ollama from 'ollama';
import ollama, { Ollama } from 'ollama';
// Mock weather functions
function getTemperature(args: { city: string }): string {
@@ -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)
+50
View File
@@ -24,6 +24,11 @@ import type {
ShowRequest,
ShowResponse,
StatusResponse,
WebSearchRequest,
WebSearchResponse,
WebFetchRequest,
WebFetchResponse,
VersionResponse,
} from './interfaces.js'
import { defaultHost } from './constant.js'
@@ -45,6 +50,8 @@ export class Ollama {
this.fetch = config?.fetch ?? fetch
}
// Abort any ongoing streamed requests to Ollama
public abort() {
for (const request of this.ongoingStreamedRequests) {
@@ -320,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 }
+56 -1
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
@@ -57,6 +58,13 @@ export interface GenerateRequest {
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>
}
@@ -110,6 +118,8 @@ export interface ChatRequest {
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>
}
@@ -160,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>
}
@@ -173,11 +184,19 @@ 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
@@ -188,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 {
@@ -202,6 +227,7 @@ export interface ChatResponse {
prompt_eval_duration: number
eval_count: number
eval_duration: number
logprobs?: Logprob[]
}
export interface EmbedResponse {
@@ -257,6 +283,10 @@ export interface ShowResponse {
projector_info?: Map<string, any>
}
export interface VersionResponse {
version: string
}
export interface ListResponse {
models: ModelResponse[]
}
@@ -268,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[]
}
+69 -28
View File
@@ -28,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
@@ -119,23 +123,27 @@ function getPlatform(): string {
* - 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> {
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;
// 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);
// 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 || {};
// 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
@@ -155,16 +163,41 @@ const fetchWithHeaders = async (
} as HeadersInit
// Normalizes headers into a plain object format.
options.headers = normalizeHeaders(options.headers);
// Filter out default headers from custom headers
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)
}
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)
@@ -176,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)
@@ -212,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)
@@ -224,7 +261,7 @@ export const post = async (
method: 'POST',
body: formattedData,
signal: options?.signal,
headers: options?.headers
headers: options?.headers,
})
await checkOk(response)
@@ -247,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)
@@ -271,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')
@@ -289,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)
@@ -332,16 +373,16 @@ export const formatHost = (host: string): string => {
}
// Build basic auth part if present
let auth = '';
let auth = ''
if (url.username) {
auth = url.username;
auth = url.username
if (url.password) {
auth += `:${url.password}`;
auth += `:${url.password}`
}
auth += '@';
auth += '@'
}
let formattedHost = `${url.protocol}//${auth}${url.hostname}:${port}${url.pathname}`;
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)
})
})
+27 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { get } from '../src/utils'
import { get, parseJSON } from '../src/utils'
describe('get Function Header Tests', () => {
const mockFetch = vi.fn();
@@ -79,4 +79,29 @@ describe('get Function Header Tests', () => {
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('использовать')
})
});