Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

draft: browser compatibility #56

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ To run the examples run:

```sh
npx tsx <folder-name>/<file-name>.ts
```
```
156 changes: 38 additions & 118 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import * as utils from './utils.js'
import 'whatwg-fetch'
import fs, { promises, createReadStream } from 'fs'
import { join, resolve, dirname } from 'path'
import { createHash } from 'crypto'
import { homedir } from 'os'

import type {
Fetch,
Expand Down Expand Up @@ -56,7 +52,7 @@

private async processStreamableRequest<T extends object>(
endpoint: string,
request: { stream?: boolean } & Record<string, any>,

Check warning on line 55 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (16)

Unexpected any. Specify a different type

Check warning on line 55 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (18)

Unexpected any. Specify a different type

Check warning on line 55 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected any. Specify a different type
): Promise<T | AsyncGenerator<T>> {
request.stream = request.stream ?? false
const response = await utils.post(
Expand All @@ -83,7 +79,7 @@
yield message
// message will be done in the case of chat and generate
// message will be success in the case of a progress response (pull, push, create)
if ((message as any).done || (message as any).status === 'success') {

Check warning on line 82 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (16)

Unexpected any. Specify a different type

Check warning on line 82 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (16)

Unexpected any. Specify a different type

Check warning on line 82 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (18)

Unexpected any. Specify a different type

Check warning on line 82 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (18)

Unexpected any. Specify a different type

Check warning on line 82 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected any. Specify a different type

Check warning on line 82 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected any. Specify a different type
return
}
}
Expand All @@ -91,7 +87,7 @@
})()
} else {
const message = await itr.next()
if (!message.value.done && (message.value as any).status !== 'success') {

Check warning on line 90 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (16)

Unexpected any. Specify a different type

Check warning on line 90 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (18)

Unexpected any. Specify a different type

Check warning on line 90 in src/index.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected any. Specify a different type
throw new Error('Expected a completed response.')
}
return message.value
Expand All @@ -104,111 +100,19 @@
const result = Buffer.from(image).toString('base64')
return result
}
try {
if (fs.existsSync(image)) {
// this is a filepath, read the file and convert it to base64
const fileBuffer = await promises.readFile(resolve(image))
return Buffer.from(fileBuffer).toString('base64')
if (utils.isNode()) {
const { readImage } = await import('../src/node.js')
try {
// if this succeeds the image exists locally at this filepath and has been read
return await readImage(image)
} catch {
// couldn't read an image at the filepath, continue
}
} catch {
// continue
}
// the string may be base64 encoded
// the string should be base64 encoded already
return image
}

private async parseModelfile(
modelfile: string,
mfDir: string = process.cwd(),
): Promise<string> {
const out: string[] = []
const lines = modelfile.split('\n')
for (const line of lines) {
const [command, args] = line.split(' ', 2)
if (['FROM', 'ADAPTER'].includes(command.toUpperCase())) {
const path = this.resolvePath(args.trim(), mfDir)
if (await this.fileExists(path)) {
out.push(`${command} @${await this.createBlob(path)}`)
} else {
out.push(`${command} ${args}`)
}
} else {
out.push(line)
}
}
return out.join('\n')
}

private resolvePath(inputPath, mfDir) {
if (inputPath.startsWith('~')) {
return join(homedir(), inputPath.slice(1))
}
return resolve(mfDir, inputPath)
}

private async fileExists(path: string): Promise<boolean> {
try {
await promises.access(path)
return true
} catch {
return false
}
}

private async createBlob(path: string): Promise<string> {
if (typeof ReadableStream === 'undefined') {
// Not all fetch implementations support streaming
// TODO: support non-streaming uploads
throw new Error('Streaming uploads are not supported in this environment.')
}

// Create a stream for reading the file
const fileStream = createReadStream(path)

// Compute the SHA256 digest
const sha256sum = await new Promise<string>((resolve, reject) => {
const hash = createHash('sha256')
fileStream.on('data', (data) => hash.update(data))
fileStream.on('end', () => resolve(hash.digest('hex')))
fileStream.on('error', reject)
})

const digest = `sha256:${sha256sum}`

try {
await utils.head(this.fetch, `${this.config.host}/api/blobs/${digest}`)
} catch (e) {
if (e instanceof Error && e.message.includes('404')) {
// Create a new readable stream for the fetch request
const readableStream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => {
controller.enqueue(chunk) // Enqueue the chunk directly
})

fileStream.on('end', () => {
controller.close() // Close the stream when the file ends
})

fileStream.on('error', (err) => {
controller.error(err) // Propagate errors to the stream
})
},
})

await utils.post(
this.fetch,
`${this.config.host}/api/blobs/${digest}`,
readableStream,
)
} else {
throw e
}
}

return digest
}

generate(
request: GenerateRequest & { stream: true },
): Promise<AsyncGenerator<GenerateResponse>>
Expand All @@ -218,7 +122,17 @@
request: GenerateRequest,
): Promise<GenerateResponse | AsyncGenerator<GenerateResponse>> {
if (request.images) {
request.images = await Promise.all(request.images.map(this.encodeImage.bind(this)))
const encodedImages: (Uint8Array | string)[] = await Promise.all(
request.images.map(this.encodeImage.bind(this)),
)
// Fix type checks by only allowing one type in the array.
if (encodedImages.length > 0) {
if (typeof encodedImages[0] === 'string') {
request.images = encodedImages as string[]
} else {
request.images = encodedImages as Uint8Array[]
}
}
}
return this.processStreamableRequest<GenerateResponse>('generate', request)
}
Expand All @@ -230,9 +144,17 @@
if (request.messages) {
for (const message of request.messages) {
if (message.images) {
message.images = await Promise.all(
const encodedImages: (Uint8Array | string)[] = await Promise.all(
message.images.map(this.encodeImage.bind(this)),
)
// Fix type checks by only allowing one type in the array.
if (encodedImages.length > 0) {
if (typeof encodedImages[0] === 'string') {
message.images = encodedImages as string[]
} else {
message.images = encodedImages as Uint8Array[]
}
}
}
}
}
Expand Down Expand Up @@ -273,23 +195,21 @@
async create(
request: CreateRequest,
): Promise<ProgressResponse | AsyncGenerator<ProgressResponse>> {
let modelfileContent = ''
if (request.path) {
modelfileContent = await promises.readFile(request.path, { encoding: 'utf8' })
modelfileContent = await this.parseModelfile(
modelfileContent,
dirname(request.path),
)
} else if (request.modelfile) {
modelfileContent = await this.parseModelfile(request.modelfile)
} else {
throw new Error('Must provide either path or modelfile to create a model')
if (utils.isNode()) {
const { readModelfile } = await import('../src/node.js')
const modelfileContent = await readModelfile(this, request)
request.modelfile = modelfileContent
}

if (request.modelfile == '') {
// request.path will resolve to a modelfile in node environments, otherwise is it required
throw new Error('modelfile is requrired')
}

return this.processStreamableRequest<ProgressResponse>('create', {
name: request.model,
stream: request.stream,
modelfile: modelfileContent,
modelfile: request.modelfile,
})
}

Expand Down
118 changes: 118 additions & 0 deletions src/node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const fs = require('fs')

Check failure on line 1 in src/node.js

View workflow job for this annotation

GitHub Actions / test (16)

Require statement not part of import statement

Check failure on line 1 in src/node.js

View workflow job for this annotation

GitHub Actions / test (18)

Require statement not part of import statement

Check failure on line 1 in src/node.js

View workflow job for this annotation

GitHub Actions / test (20)

Require statement not part of import statement
const path = require('path')

Check failure on line 2 in src/node.js

View workflow job for this annotation

GitHub Actions / test (16)

Require statement not part of import statement

Check failure on line 2 in src/node.js

View workflow job for this annotation

GitHub Actions / test (18)

Require statement not part of import statement

Check failure on line 2 in src/node.js

View workflow job for this annotation

GitHub Actions / test (20)

Require statement not part of import statement
const utils = require('./utils')

Check failure on line 3 in src/node.js

View workflow job for this annotation

GitHub Actions / test (16)

Require statement not part of import statement

Check failure on line 3 in src/node.js

View workflow job for this annotation

GitHub Actions / test (18)

Require statement not part of import statement

Check failure on line 3 in src/node.js

View workflow job for this annotation

GitHub Actions / test (20)

Require statement not part of import statement
const { createHash } = require('crypto')

Check failure on line 4 in src/node.js

View workflow job for this annotation

GitHub Actions / test (16)

Require statement not part of import statement

Check failure on line 4 in src/node.js

View workflow job for this annotation

GitHub Actions / test (18)

Require statement not part of import statement

Check failure on line 4 in src/node.js

View workflow job for this annotation

GitHub Actions / test (20)

Require statement not part of import statement
const { homedir } = require('os')

Check failure on line 5 in src/node.js

View workflow job for this annotation

GitHub Actions / test (16)

Require statement not part of import statement

Check failure on line 5 in src/node.js

View workflow job for this annotation

GitHub Actions / test (18)

Require statement not part of import statement

Check failure on line 5 in src/node.js

View workflow job for this annotation

GitHub Actions / test (20)

Require statement not part of import statement

async function parseModelfile(ollama, modelfile, mfDir = process.cwd()) {
const out = []
const lines = modelfile.split('\n')
for (const line of lines) {
const [command, args] = line.split(' ', 2)
if (['FROM', 'ADAPTER'].includes(command.toUpperCase())) {
const resolvedPath = resolvePath(args.trim(), mfDir)
if (await fileExists(resolvedPath)) {
out.push(`${command} @${await createBlob(ollama, resolvedPath)}`)
} else {
out.push(`${command} ${args}`)
}
} else {
out.push(line)
}
}
return out.join('\n')
}

function resolvePath(inputPath, mfDir) {
if (inputPath.startsWith('~')) {
return path.join(homedir(), inputPath.slice(1))
}
return path.resolve(mfDir, inputPath)
}

async function fileExists(filePath) {
try {
await fs.promises.access(filePath)
return true
} catch {
return false
}
}

async function createBlob(ollama, filePath) {
if (typeof ReadableStream === 'undefined') {
throw new Error('Streaming uploads are not supported in this environment.')
}

const fileStream = fs.createReadStream(filePath)
const sha256sum = await new Promise((resolve, reject) => {
const hash = createHash('sha256')
fileStream.on('data', (data) => hash.update(data))
fileStream.on('end', () => resolve(hash.digest('hex')))
fileStream.on('error', reject)
})

const digest = `sha256:${sha256sum}`

try {
await utils.head(ollama.fetch, `${ollama.config.host}/api/blobs/${digest}`, {
signal: ollama.abortController.signal,
})
} catch (e) {
if (e instanceof Error && e.message.includes('404')) {
const readableStream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => {
controller.enqueue(chunk)
})

fileStream.on('end', () => {
controller.close()
})

fileStream.on('error', (err) => {
controller.error(err)
})
},
})

await utils.post(
ollama.fetch,
`${ollama.config.host}/api/blobs/${digest}`,
readableStream,
{ signal: ollama.abortController.signal },
)
} else {
throw e
}
}

return digest
}

export async function readModelfile(ollama, request) {
let modelfileContent = ''
if (request.path) {
modelfileContent = await fs.promises.readFile(request.path, { encoding: 'utf8' })
modelfileContent = await parseModelfile(
ollama,
modelfileContent,
path.dirname(request.path),
)
} else if (request.modelfile) {
modelfileContent = await parseModelfile(ollama, request.modelfile)
} else {
throw new Error('Must provide either path or modelfile to create a model')
}

return modelfileContent
}

export async function readImage(imgPath) {
if (fs.existsSync(imgPath)) {
// this is a filepath, read the file and convert it to base64
const fileBuffer = await fs.promises.readFile(path.resolve(imgPath))
return Buffer.from(fileBuffer).toString('base64')
}
throw new Error(`Image path ${imgPath} does not exist`)
}
8 changes: 8 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@
return '' // unknown
}

export const isNode = () => {
return (
typeof process !== 'undefined' &&
process.versions != null &&
process.versions.node != null
)
}

const fetchWithHeaders = async (
fetch: Fetch,
url: string,
Expand Down Expand Up @@ -97,7 +105,7 @@
data?: Record<string, unknown> | BodyInit,
options?: { signal: AbortSignal },
): Promise<Response> => {
const isRecord = (input: any): input is Record<string, unknown> => {

Check warning on line 108 in src/utils.ts

View workflow job for this annotation

GitHub Actions / test (16)

Unexpected any. Specify a different type

Check warning on line 108 in src/utils.ts

View workflow job for this annotation

GitHub Actions / test (18)

Unexpected any. Specify a different type

Check warning on line 108 in src/utils.ts

View workflow job for this annotation

GitHub Actions / test (20)

Unexpected any. Specify a different type
return input !== null && typeof input === 'object' && !Array.isArray(input)
}

Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"esm": true,
},

"include": ["./src/**/*.ts"],
"include": ["./src/**/*.ts", "src/node.js"],

"exclude": ["node_modules"],
}
Loading