Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ jobs:

# Validate that the tests pass without the pnpm overrides that alter
# the intra-monorepo dependencies for local development.
- run: cat package.json | jq 'del(.pnpm.overrides)' | tee package.json
- run: pnpm install --no-frozen-lockfile
- run: pnpm run test
# - run: cat package.json | jq 'del(.pnpm.overrides)' | tee package.json
# - run: pnpm install --no-frozen-lockfile
# - run: pnpm run test

# Validate that the prepublish hook works.
- run: pnpm run prepublish
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"trailingComma": "es5"
"trailingCommas": "es5"
}
}
}
4 changes: 2 additions & 2 deletions examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@formula-monks/kurt-open-ai": "workspace:*",
"@formula-monks/kurt-vertex-ai": "workspace:*",
"@google-cloud/vertexai": "1.9.3",
"openai": "4.85.1",
"zod": "^3.23.8"
"openai": "4.89.1",
"zod": "^3.24.2"
}
}
6 changes: 3 additions & 3 deletions packages/kurt-cache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
"extends": "semantic-release-monorepo"
},
"dependencies": {
"@formula-monks/kurt": "^1.5.0",
"@formula-monks/kurt": "^1.6.0",
"yaml": "^2.4.5",
"zod-to-json-schema": "^3.23.3"
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
Expand All @@ -40,6 +40,6 @@
"ts-jest": "^29.1.2",
"type-fest": "^4.30.0",
"typescript": "^5.4.5",
"zod": "^3.23.8"
"zod": "^3.24.2"
}
}
10 changes: 5 additions & 5 deletions packages/kurt-open-ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"build": "tsc --build",
"prepack": "pnpm run build",
"format": "pnpm biome format --write .",
"lint": "pnpm biome lint --apply .",
"lint": "pnpm biome lint --write .",
"check": "pnpm biome check .",
"prepublish": "../../scripts/interpolate-example-code.sh README.md",
"release": "pnpm exec semantic-release"
Expand All @@ -27,10 +27,10 @@
"extends": "semantic-release-monorepo"
},
"dependencies": {
"@formula-monks/kurt": "^1.5.0",
"openai": "4.85.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.3"
"@formula-monks/kurt": "^1.6.0",
"openai": "4.89.1",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
Expand Down
14 changes: 8 additions & 6 deletions packages/kurt-open-ai/spec/generateNaturalLanguage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ describe("KurtOpenAI generateNaturalLanguage", () => {
)
expect(result.text).toEqual(
[
"Moonlight gently gleams,",
"Whispers of the stream below,",
"Stars in water dream.",
"Moonlight threads the pines, ",
"Whispers ripple in the stream",
"Stone dreams in darkness.",
].join("\n")
)
})
Expand All @@ -45,7 +45,9 @@ describe("KurtOpenAI generateNaturalLanguage", () => {
expect(errorAny).toBeInstanceOf(KurtResultLimitError)
const error = errorAny as KurtResultLimitError

expect(error.text).toEqual("Words constrained by bounds,\n")
expect(error.text).toEqual(
"The maximum output tokens limit was below the minimum value"
)
}
)
})
Expand All @@ -66,7 +68,7 @@ describe("KurtOpenAI generateNaturalLanguage", () => {
],
})
)
expect(result.text).toEqual("Heart eyes")
expect(result.text).toEqual("Heart eyes.")
})

test("describes a base64-encoded image (inlineData)", async () => {
Expand All @@ -85,7 +87,7 @@ describe("KurtOpenAI generateNaturalLanguage", () => {
],
})
)
expect(result.text).toEqual("Heart eyes")
expect(result.text).toEqual("Heart eyes.")
})

test("throws an error when a message includes inline audio data", async () => {
Expand Down
23 changes: 8 additions & 15 deletions packages/kurt-open-ai/spec/generateStructuredData.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, test, expect } from "@jest/globals"
import { describe, expect, test } from "@jest/globals"
import { z } from "zod"
import { snapshotAndMock, snapshotAndMockWithError } from "./snapshots"
import {
KurtCapabilityError,
KurtResultValidateError,
KurtInvalidInputSchemaError,
} from "@formula-monks/kurt"

describe("KurtOpenAI generateStructuredData", () => {
Expand Down Expand Up @@ -48,7 +48,7 @@ describe("KurtOpenAI generateStructuredData", () => {
sampling: { forceSchemaConstrainedTokens: true },
})
)
expect(result.data).toEqual({ say: "hello" })
expect(result.data).toEqual({ say: "Hello!" })
})

test("throws a capability error for schema constrained tokens in an older model", async () => {
Expand Down Expand Up @@ -92,19 +92,12 @@ describe("KurtOpenAI generateStructuredData", () => {
}),

(errorAny) => {
expect(errorAny).toBeInstanceOf(KurtResultValidateError)
const error = errorAny as KurtResultValidateError
expect(errorAny).toBeInstanceOf(KurtInvalidInputSchemaError)
const error = errorAny as KurtInvalidInputSchemaError

expect(error.text).toEqual('{"say":"hello"}')
expect(error.data).toEqual({ say: "hello" })
expect(error.cause.issues).toEqual([
{
code: "invalid_string",
path: ["say"],
validation: "regex",
message: "Invalid",
},
])
expect(error.message).toEqual(
"400 Invalid schema for function 'structured_data': In context=('properties', 'say'), 'pattern' is not permitted."
)
}
)
})
Expand Down
42 changes: 38 additions & 4 deletions packages/kurt-open-ai/spec/generateWithOptionalTools.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, test, expect } from "@jest/globals"
import { describe, expect, test } from "@jest/globals"
import { z } from "zod"
import { snapshotAndMock, snapshotAndMockWithError } from "./snapshots"
import { KurtResultLimitError } from "@formula-monks/kurt"
import { snapshotAndMock } from "./snapshots"
import { KurtTools } from "@formula-monks/kurt/dist/KurtTools"

const calculatorTools = {
subtract: z
Expand Down Expand Up @@ -69,7 +69,7 @@ describe("KurtOpenAI generateWithOptionalTools", () => {
})
)
expect(result.text).toEqual(
"9876356 divided by 30487, rounded to the nearest integer, is approximately 324."
"9876356 divided by 30487 is approximately 323.95. Rounded to the nearest integer, the result is 324."
)
})

Expand Down Expand Up @@ -150,6 +150,40 @@ describe("KurtOpenAI generateWithOptionalTools", () => {
)
})

test("uses a kurt tool to search the web", async () => {
const result = await snapshotAndMock("gpt-4o-2024-11-20", (kurt) =>
kurt.generateWithOptionalTools({
prompt: [
"What is the weather in Lisbon, Portugal today? Respond with only one sentence, start by saying which day it is.",
].join("\n"),
tools: {
webSearch: KurtTools.WebSearch(),
},
})
)
expect(result.text).toEqual(
"Thursday, March 27, 2025, in Lisbon, Portugal, is mostly cloudy with a high of 61°F (16°C) and a low of 50°F (10°C). "
)
})

test("can use both a kurt tool and an external tool", async () => {
const result = await snapshotAndMock("gpt-4o-2024-11-20", (kurt) =>
kurt.generateWithOptionalTools({
prompt: [
"Get the current weather in Lisbon and divide the temperature in Celsius by 0.79",
].join("\n"),
tools: {
webSearch: KurtTools.WebSearch(),
...calculatorTools,
},
})
)
expect(result.data).toEqual({
name: "divide",
args: { dividend: 14, divisor: 0.79 },
})
})

// The below test is commented out because this test case currently breaks
// OpenAI's API (causes a 5xx server error).
//
Expand Down
2 changes: 1 addition & 1 deletion packages/kurt-open-ai/spec/isSupportedModel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "@jest/globals"
import { KurtOpenAI, type KurtOpenAISupportedModel } from "../src/KurtOpenAI"
import { KurtOpenAI, type KurtOpenAISupportedModel } from "../src"

test("KurtOpenAI.isSupportedModel", () => {
// For updating this test, the current list of models can be found at:
Expand Down
98 changes: 65 additions & 33 deletions packages/kurt-open-ai/spec/snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect } from "@jest/globals"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { OpenAI as RealOpenAI } from "openai"
import { BadRequestError, OpenAI as RealOpenAI } from "openai"
import {
Kurt,
type KurtStream,
Expand All @@ -13,7 +13,9 @@ import type {
OpenAIResponse,
OpenAIResponseChunk,
} from "../src/OpenAI.types"
import { KurtOpenAI, type KurtOpenAISupportedModel } from "../src/KurtOpenAI"
import { KurtOpenAI, type KurtOpenAISupportedModel } from "../src"
import type { ResponseStreamEvent } from "openai/resources/responses/responses"
import type { Stream } from "openai/streaming"

function snapshotFilenameFor(testName: string | undefined) {
return `${__dirname}/snapshots/${testName?.replace(/ /g, "_")}.yaml`
Expand All @@ -28,17 +30,33 @@ function dumpYaml(filename: string, data: object) {
writeFileSync(filename, stringifyYaml(data))
}

function createConcreteErrorType(error?: Error) {
if (!error) return error
const badRequestError = error as BadRequestError
if (badRequestError.type === "invalid_request_error") {
return new BadRequestError(
400,
badRequestError.error,
badRequestError.message,
badRequestError.headers
)
}
return error
}

export async function snapshotAndMock<T>(
model: KurtOpenAISupportedModel,
testCaseFn: (kurt: Kurt) => KurtStream<T>
) {
// Here's the data structure we will use to snapshot a request/response cycle.
const snapshot: {
step1Request?: OpenAIRequest
step1Error?: Error
step2RawChunks: OpenAIResponseChunk[]
step3KurtEvents: KurtStreamEvent<T>[]
} = {
step1Request: undefined,
step1Error: undefined,
step2RawChunks: [],
step3KurtEvents: [],
}
Expand All @@ -48,45 +66,59 @@ export async function snapshotAndMock<T>(
expect.getState().currentTestName
)
const savedSnapshot = loadYaml(snapshotFilename) as Required<typeof snapshot>
if (savedSnapshot) {
const concreteError = createConcreteErrorType(savedSnapshot.step1Error)
if (concreteError) savedSnapshot.step1Error = concreteError
}

// Create a fake OpenAI instance that captures the request and response,
// and will only delegate to "real OpenAI" if there is no saved snapshot.
const openAI = {
chat: {
completions: {
async create(request: OpenAIRequest): OpenAIResponse {
snapshot.step1Request = request

// If we have a saved snapshot, use it as a mock API.
if (savedSnapshot?.step2RawChunks) {
const savedRawChunks = savedSnapshot.step2RawChunks
snapshot.step2RawChunks = savedRawChunks
async function* generator(): AsyncIterable<OpenAIResponseChunk> {
for await (const rawChunk of savedRawChunks) {
yield rawChunk
}
}
return generator()
}
responses: {
async create(request: OpenAIRequest): OpenAIResponse {
snapshot.step1Request = request
if (savedSnapshot?.step1Error) {
snapshot.step1Error = savedSnapshot.step1Error
throw savedSnapshot.step1Error
}
// If we have a saved snapshot, use it as a mock API.
if (savedSnapshot?.step2RawChunks) {
const savedRawChunks = savedSnapshot.step2RawChunks
snapshot.step2RawChunks = savedRawChunks

// Otherwise, use the real API (and capture the raw chunks to save).
const realOpenAI = new RealOpenAI()
const response = await realOpenAI.chat.completions.create(request)
async function* gen() {
for await (const rawChunk of response) {
// Snapshot the parts we care about from the raw chunk.
snapshot.step2RawChunks.push({
choices: rawChunk.choices,
system_fingerprint: rawChunk.system_fingerprint,
usage: (rawChunk as OpenAIResponseChunk).usage,
})

// Yield the raw chunk to the adapter.
// @ts-ignore
async function* generator(): AsyncIterable<OpenAIResponseChunk> {
for await (const rawChunk of savedRawChunks) {
yield rawChunk
}
}
return gen()
},

return generator()
}

// Otherwise, use the real API (and capture the raw chunks to save).
const realOpenAI = new RealOpenAI()

let response: Stream<ResponseStreamEvent>

try {
response = await realOpenAI.responses.create(request)
} catch (e) {
if (e instanceof Error) snapshot.step1Error = e
throw e
}

async function* gen() {
for await (const rawChunk of response) {
// Snapshot the parts we care about from the raw chunk.
snapshot.step2RawChunks.push(rawChunk)

// Yield the raw chunk to the adapter.
yield rawChunk
}
}

return gen()
},
},
} as unknown as OpenAI
Expand Down
Loading