pull/643/head^2
Travis Fischer 2024-06-03 15:06:41 -05:00
rodzic ed85b708fd
commit f5319b4e17
15 zmienionych plików z 494 dodań i 173 usunięć

Wyświetl plik

@ -1,4 +1,8 @@
{
"root": true,
"extends": ["@fisch0920/eslint-config/node"]
"extends": ["@fisch0920/eslint-config/node"],
"rules": {
"unicorn/no-static-only-class": "off",
"@typescript-eslint/naming-convention": "off"
}
}

Wyświetl plik

@ -71,7 +71,7 @@ async function main() {
// })
const res = await diffbot.enhanceEntity({
type: 'Person',
name: 'Travis Fischer'
name: 'Kevin Raheja'
})
console.log(JSON.stringify(res, null, 2))
}

Wyświetl plik

@ -59,6 +59,7 @@
},
"dependencies": {
"@nangohq/node": "^0.39.32",
"dedent": "^1.5.3",
"delay": "^6.0.0",
"jsonrepair": "^3.6.1",
"ky": "^1.2.4",
@ -85,6 +86,7 @@
"np": "^10.0.5",
"npm-run-all2": "^6.2.0",
"only-allow": "^1.2.1",
"openai-fetch": "^2.0.3",
"prettier": "^3.2.5",
"restore-cursor": "^5.0.0",
"ts-node": "^10.9.2",

Wyświetl plik

@ -14,6 +14,9 @@ importers:
'@nangohq/node':
specifier: ^0.39.32
version: 0.39.32
dedent:
specifier: ^1.5.3
version: 1.5.3
delay:
specifier: ^6.0.0
version: 6.0.0
@ -87,6 +90,9 @@ importers:
only-allow:
specifier: ^1.2.1
version: 1.2.1
openai-fetch:
specifier: ^2.0.3
version: 2.0.3
prettier:
specifier: ^3.2.5
version: 3.3.0

Wyświetl plik

@ -3,9 +3,9 @@ export * from './create-ai-function.js'
export * from './create-ai-function.js'
export * from './errors.js'
export * from './fns.js'
export * from './message.js'
export * from './parse-structured-output.js'
export * from './services/index.js'
export * from './stringify-for-model.js'
export type * from './types.js'
export * from './utils.js'
export * from './zod-to-json-schema.js'

Wyświetl plik

@ -0,0 +1,55 @@
import type * as OpenAI from 'openai-fetch'
import { describe, expect, expectTypeOf, it } from 'vitest'
import type * as types from './types.js'
import { Msg } from './message.js'
describe('Msg', () => {
it('creates a message and fixes indentation', () => {
const msgContent = `
Hello, World!
`
const msg = Msg.system(msgContent)
expect(msg.role).toEqual('system')
expect(msg.content).toEqual('Hello, World!')
})
it('supports disabling indentation fixing', () => {
const msgContent = `
Hello, World!
`
const msg = Msg.system(msgContent, { cleanContent: false })
expect(msg.content).toEqual('\n Hello, World!\n ')
})
it('handles tool calls request', () => {
const msg = Msg.toolCall([
{
id: 'fake-tool-call-id',
type: 'function',
function: {
arguments: '{"prompt": "Hello, World!"}',
name: 'hello'
}
}
])
expectTypeOf(msg).toMatchTypeOf<types.Msg.ToolCall>()
expect(Msg.isToolCall(msg)).toBe(true)
})
it('handles tool call response', () => {
const msg = Msg.toolResult('Hello, World!', 'fake-tool-call-id')
expectTypeOf(msg).toMatchTypeOf<types.Msg.ToolResult>()
expect(Msg.isToolResult(msg)).toBe(true)
})
it('prompt message types should interop with openai-fetch message types', () => {
expectTypeOf({} as OpenAI.ChatMessage).toMatchTypeOf<types.Msg>()
expectTypeOf({} as types.Msg).toMatchTypeOf<OpenAI.ChatMessage>()
expectTypeOf({} as types.Msg.System).toMatchTypeOf<OpenAI.ChatMessage>()
expectTypeOf({} as types.Msg.User).toMatchTypeOf<OpenAI.ChatMessage>()
expectTypeOf({} as types.Msg.Assistant).toMatchTypeOf<OpenAI.ChatMessage>()
expectTypeOf({} as types.Msg.FuncCall).toMatchTypeOf<OpenAI.ChatMessage>()
expectTypeOf({} as types.Msg.FuncResult).toMatchTypeOf<OpenAI.ChatMessage>()
})
})

344
src/message.ts 100644
Wyświetl plik

@ -0,0 +1,344 @@
import type { Jsonifiable } from 'type-fest'
import { cleanStringForModel, stringifyForModel } from './utils.js'
/**
* Generic/default OpenAI message without any narrowing applied.
*/
export interface Msg {
/**
* The contents of the message. `content` is required for all messages, and
* may be null for assistant messages with function calls.
*/
content: string | null
/**
* The role of the messages author. One of `system`, `user`, `assistant`,
* 'tool', or `function`.
*/
role: Msg.Role
/**
* The name and arguments of a function that should be called, as generated
* by the model.
*/
function_call?: Msg.Call.Function
/**
* The tool calls generated by the model, such as function calls.
*/
tool_calls?: Msg.Call.Tool[]
/**
* Tool call that this message is responding to.
*/
tool_call_id?: string
/**
* The name of the author of this message. `name` is required if role is
* `function`, and it should be the name of the function whose response is in the
* `content`. May contain a-z, A-Z, 0-9, and underscores, with a maximum length of
* 64 characters.
*/
name?: string
}
/** Narrowed OpenAI Message types. */
export namespace Msg {
/** Possible roles for a message. */
export type Role = 'system' | 'user' | 'assistant' | 'function' | 'tool'
export namespace Call {
/**
* The name and arguments of a function that should be called, as generated
* by the model.
*/
export type Function = {
/**
* The arguments to call the function with, as generated by the model in
* JSON format.
*/
arguments: string
/** The name of the function to call. */
name: string
}
/** The tool calls generated by the model, such as function calls. */
export type Tool = {
/** The ID of the tool call. */
id: string
/** The type of the tool. Currently, only `function` is supported. */
type: 'function'
/** The function that the model called. */
function: Call.Function
}
}
/** Message with text content for the system. */
export type System = {
role: 'system'
content: string
name?: string
}
/** Message with text content from the user. */
export type User = {
role: 'user'
name?: string
content: string
}
/** Message with text content from the assistant. */
export type Assistant = {
role: 'assistant'
name?: string
content: string
}
/** Message with arguments to call a function. */
export type FuncCall = {
role: 'assistant'
name?: string
content: null
function_call: Call.Function
}
/** Message with the result of a function call. */
export type FuncResult = {
role: 'function'
name: string
content: string
}
/** Message with arguments to call one or more tools. */
export type ToolCall = {
role: 'assistant'
name?: string
content: null
tool_calls: Call.Tool[]
}
/** Message with the result of a tool call. */
export type ToolResult = {
role: 'tool'
tool_call_id: string
content: string
}
}
/** Utility functions for creating and checking message types. */
export namespace Msg {
/** Create a system message. Cleans indentation and newlines by default. */
export function system(
content: string,
opts?: {
/** Custom name for the message. */
name?: string
/** Whether to clean extra newlines and indentation. Defaults to true. */
cleanContent?: boolean
}
): Msg.System {
const { name, cleanContent = true } = opts ?? {}
return {
role: 'system',
content: cleanContent ? cleanStringForModel(content) : content,
...(name ? { name } : {})
}
}
/** Create a user message. Cleans indentation and newlines by default. */
export function user(
content: string,
opts?: {
/** Custom name for the message. */
name?: string
/** Whether to clean extra newlines and indentation. Defaults to true. */
cleanContent?: boolean
}
): Msg.User {
const { name, cleanContent = true } = opts ?? {}
return {
role: 'user',
content: cleanContent ? cleanStringForModel(content) : content,
...(name ? { name } : {})
}
}
/** Create an assistant message. Cleans indentation and newlines by default. */
export function assistant(
content: string,
opts?: {
/** Custom name for the message. */
name?: string
/** Whether to clean extra newlines and indentation. Defaults to true. */
cleanContent?: boolean
}
): Msg.Assistant {
const { name, cleanContent = true } = opts ?? {}
return {
role: 'assistant',
content: cleanContent ? cleanStringForModel(content) : content,
...(name ? { name } : {})
}
}
/** Create a function call message with argumets. */
export function funcCall(
function_call: {
/** Name of the function to call. */
name: string
/** Arguments to pass to the function. */
arguments: string
},
opts?: {
/** The name descriptor for the message.(message.name) */
name?: string
}
): Msg.FuncCall {
return {
...opts,
role: 'assistant',
content: null,
function_call
}
}
/** Create a function result message. */
export function funcResult(
content: Jsonifiable,
name: string
): Msg.FuncResult {
const contentString = stringifyForModel(content)
return { role: 'function', content: contentString, name }
}
/** Create a function call message with argumets. */
export function toolCall(
tool_calls: Msg.Call.Tool[],
opts?: {
/** The name descriptor for the message.(message.name) */
name?: string
}
): Msg.ToolCall {
return {
...opts,
role: 'assistant',
content: null,
tool_calls
}
}
/** Create a tool call result message. */
export function toolResult(
content: Jsonifiable,
tool_call_id: string,
opts?: {
/** The name of the tool which was called */
name?: string
}
): Msg.ToolResult {
const contentString = stringifyForModel(content)
return { ...opts, role: 'tool', tool_call_id, content: contentString }
}
/** Get the narrowed message from an EnrichedResponse. */
export function getMessage(
// @TODO
response: any
// response: ChatModel.EnrichedResponse
): Msg.Assistant | Msg.FuncCall | Msg.ToolCall {
const msg = response.choices[0].message as Msg
return narrowResponseMessage(msg)
}
/** Narrow a message received from the API. It only responds with role=assistant */
export function narrowResponseMessage(
msg: Msg
): Msg.Assistant | Msg.FuncCall | Msg.ToolCall {
if (msg.content === null && msg.tool_calls != null) {
return Msg.toolCall(msg.tool_calls)
} else if (msg.content === null && msg.function_call != null) {
return Msg.funcCall(msg.function_call)
} else if (msg.content !== null) {
return Msg.assistant(msg.content)
} else {
// @TODO: probably don't want to error here
console.log('Invalid message', msg)
throw new Error('Invalid message')
}
}
/** Check if a message is a system message. */
export function isSystem(message: Msg): message is Msg.System {
return message.role === 'system'
}
/** Check if a message is a user message. */
export function isUser(message: Msg): message is Msg.User {
return message.role === 'user'
}
/** Check if a message is an assistant message. */
export function isAssistant(message: Msg): message is Msg.Assistant {
return message.role === 'assistant' && message.content !== null
}
/** Check if a message is a function call message with arguments. */
export function isFuncCall(message: Msg): message is Msg.FuncCall {
return message.role === 'assistant' && message.function_call != null
}
/** Check if a message is a function result message. */
export function isFuncResult(message: Msg): message is Msg.FuncResult {
return message.role === 'function' && message.name != null
}
/** Check if a message is a tool calls message. */
export function isToolCall(message: Msg): message is Msg.ToolCall {
return message.role === 'assistant' && message.tool_calls != null
}
/** Check if a message is a tool call result message. */
export function isToolResult(message: Msg): message is Msg.ToolResult {
return message.role === 'tool' && !!message.tool_call_id
}
/** Narrow a ChatModel.Message to a specific type. */
export function narrow(message: Msg.System): Msg.System
export function narrow(message: Msg.User): Msg.User
export function narrow(message: Msg.Assistant): Msg.Assistant
export function narrow(message: Msg.FuncCall): Msg.FuncCall
export function narrow(message: Msg.FuncResult): Msg.FuncResult
export function narrow(message: Msg.ToolCall): Msg.ToolCall
export function narrow(message: Msg.ToolResult): Msg.ToolResult
export function narrow(
message: Msg
):
| Msg.System
| Msg.User
| Msg.Assistant
| Msg.FuncCall
| Msg.FuncResult
| Msg.ToolCall
| Msg.ToolResult {
if (isSystem(message)) {
return message
}
if (isUser(message)) {
return message
}
if (isAssistant(message)) {
return message
}
if (isFuncCall(message)) {
return message
}
if (isFuncResult(message)) {
return message
}
if (isToolCall(message)) {
return message
}
if (isToolResult(message)) {
return message
}
throw new Error('Invalid message type')
}
}

Wyświetl plik

@ -2,7 +2,7 @@ import { DynamicStructuredTool } from '@langchain/core/tools'
import type { AIFunctionLike } from '../types.js'
import { AIFunctionSet } from '../ai-function-set.js'
import { stringifyForModel } from '../stringify-for-model.js'
import { stringifyForModel } from '../utils.js'
/**
* Converts a set of Agentic stdlib AI functions to an array of LangChain-

Wyświetl plik

@ -1,9 +1,18 @@
import defaultKy, { type KyInstance } from 'ky'
import { z } from 'zod'
import type * as types from '../types.js'
import { aiFunction, AIFunctionsProvider } from '../fns.js'
import { Msg } from '../message.js'
import { assert, getEnv } from '../utils.js'
export class DexaClient {
export namespace dexa {
export const AskDexaOptionsSchema = z.object({
question: z.string().describe('The question to ask Dexa.')
})
export type AskDexaOptions = z.infer<typeof AskDexaOptionsSchema>
}
export class DexaClient extends AIFunctionsProvider {
readonly apiKey: string
readonly apiBaseUrl: string
readonly ky: KyInstance
@ -23,6 +32,7 @@ export class DexaClient {
apiKey,
'DexaClient missing required "apiKey" (defaults to "DEXA_API_KEY")'
)
super()
this.apiKey = apiKey
this.apiBaseUrl = apiBaseUrl
@ -30,12 +40,18 @@ export class DexaClient {
this.ky = ky.extend({ prefixUrl: this.apiBaseUrl, timeout: timeoutMs })
}
async askDexa({ messages }: { messages: types.Msg[] }) {
@aiFunction({
name: 'ask_dexa',
description:
'Answers questions based on knowledge of trusted experts and podcasters. Example experts include: Andrew Huberman, Tim Ferriss, Lex Fridman, Peter Attia, Seth Godin, Rhonda Patrick, Rick Rubin, and more.',
inputSchema: dexa.AskDexaOptionsSchema
})
async askDexa(opts: dexa.AskDexaOptions) {
return this.ky
.post('api/ask-dexa', {
json: {
secret: this.apiKey,
messages
messages: [Msg.user(opts.question)]
}
})
.json<string>()

Wyświetl plik

@ -414,6 +414,7 @@ export namespace diffbot {
locations?: Location[]
location?: Location
interests?: Interest[]
emailAddresses?: any
age?: number
crawlTimestamp?: number
}

Wyświetl plik

@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import { stringifyForModel } from './stringify-for-model.js'
describe('stringifyForModel', () => {
it('handles basic objects', () => {
const input = {
foo: 'bar',
nala: ['is', 'cute'],
kittens: null,
cats: undefined,
paws: 4.3
}
const result = stringifyForModel(input)
expect(result).toEqual(JSON.stringify(input, null))
})
it('handles empty input', () => {
const result = stringifyForModel()
expect(result).toEqual('')
})
})

Wyświetl plik

@ -1,16 +0,0 @@
import type { Jsonifiable } from 'type-fest'
/**
* Stringifies a JSON value in a way that's optimized for use with LLM prompts.
*/
export function stringifyForModel(jsonObject?: Jsonifiable): string {
if (jsonObject === undefined) {
return ''
}
if (typeof jsonObject === 'string') {
return jsonObject
}
return JSON.stringify(jsonObject, null, 0)
}

Wyświetl plik

@ -3,7 +3,9 @@ import type { z } from 'zod'
import type { AIFunctionSet } from './ai-function-set.js'
import type { AIFunctionsProvider } from './fns.js'
import type { Msg } from './message.js'
export type { Msg } from './message.js'
export type { KyInstance } from 'ky'
export type { ThrottledFunction } from 'p-throttle'
@ -70,128 +72,3 @@ export interface AITool<
/** The tool spec for the OpenAI API `tools` property. */
spec: AIToolSpec
}
/**
* Generic/default OpenAI message without any narrowing applied.
*/
export interface Msg {
/**
* The contents of the message. `content` is required for all messages, and
* may be null for assistant messages with function calls.
*/
content: string | null
/**
* The role of the messages author. One of `system`, `user`, `assistant`,
* 'tool', or `function`.
*/
role: Msg.Role
/**
* The name and arguments of a function that should be called, as generated
* by the model.
*/
function_call?: Msg.Call.Function
/** The tool calls generated by the model, such as function calls. */
tool_calls?: Msg.Call.Tool[]
/**
* Tool call that this message is responding to.
*/
tool_call_id?: string
/**
* The name of the author of this message. `name` is required if role is
* `function`, and it should be the name of the function whose response is in the
* `content`. May contain a-z, A-Z, 0-9, and underscores, with a maximum length of
* 64 characters.
*/
name?: string
}
/** Narrowed Message types. */
export namespace Msg {
/** Possible roles for a message. */
export type Role = 'system' | 'user' | 'assistant' | 'function' | 'tool'
export namespace Call {
/**
* The name and arguments of a function that should be called, as generated
* by the model.
*/
export type Function = {
/**
* The arguments to call the function with, as generated by the model in
* JSON format.
*/
arguments: string
/** The name of the function to call. */
name: string
}
/** The tool calls generated by the model, such as function calls. */
export type Tool = {
/** The ID of the tool call. */
id: string
/** The type of the tool. Currently, only `function` is supported. */
type: 'function'
/** The function that the model called. */
function: Call.Function
}
}
/** Message with text content for the system. */
export type System = {
role: 'system'
content: string
name?: string
}
/** Message with text content from the user. */
export type User = {
role: 'user'
name?: string
content: string
}
/** Message with text content from the assistant. */
export type Assistant = {
role: 'assistant'
name?: string
content: string
}
/** Message with arguments to call a function. */
export type FuncCall = {
role: 'assistant'
name?: string
content: null
function_call: Call.Function
}
/** Message with the result of a function call. */
export type FuncResult = {
role: 'function'
name: string
content: string
}
/** Message with arguments to call one or more tools. */
export type ToolCall = {
role: 'assistant'
name?: string
content: null
tool_calls: Call.Tool[]
}
/** Message with the result of a tool call. */
export type ToolResult = {
role: 'tool'
tool_call_id: string
content: string
}
}

Wyświetl plik

@ -1,9 +1,15 @@
import ky from 'ky'
import pThrottle from 'p-throttle'
import { expect, test } from 'vitest'
import { describe, expect, test } from 'vitest'
import { mockKyInstance } from './_utils.js'
import { omit, pick, sanitizeSearchParams, throttleKy } from './utils.js'
import {
omit,
pick,
sanitizeSearchParams,
stringifyForModel,
throttleKy
} from './utils.js'
test('pick', () => {
expect(pick({ a: 1, b: 2, c: 3 }, 'a', 'c')).toEqual({ a: 1, c: 3 })
@ -79,3 +85,22 @@ test(
timeout: 60_000
}
)
describe('stringifyForModel', () => {
test('handles basic objects', () => {
const input = {
foo: 'bar',
nala: ['is', 'cute'],
kittens: null,
cats: undefined,
paws: 4.3
}
const result = stringifyForModel(input)
expect(result).toEqual(JSON.stringify(input, null))
})
test('handles empty input', () => {
const result = stringifyForModel()
expect(result).toEqual('')
})
})

Wyświetl plik

@ -1,3 +1,6 @@
import type { Jsonifiable } from 'type-fest'
import dedent from 'dedent'
import type * as types from './types.js'
export { assert } from './assert.js'
@ -111,3 +114,29 @@ export function sanitizeSearchParams(
})
)
}
/**
* Stringifies a JSON value in a way that's optimized for use with LLM prompts.
*/
export function stringifyForModel(jsonObject?: Jsonifiable): string {
if (jsonObject === undefined) {
return ''
}
if (typeof jsonObject === 'string') {
return jsonObject
}
return JSON.stringify(jsonObject, null, 0)
}
const dedenter = dedent.withOptions({ escapeSpecialCharacters: true })
/**
* Clean a string by removing extra newlines and indentation.
*
* @see: https://github.com/dmnd/dedent
*/
export function cleanStringForModel(text: string): string {
return dedenter(text).trim()
}