1559 lines
51 KiB
TypeScript
1559 lines
51 KiB
TypeScript
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai-compat"
|
|
import { assert, describe, it } from "@effect/vitest"
|
|
import { Effect, Layer, Redacted, Ref, Schema, Stream } from "effect"
|
|
import { LanguageModel, Prompt, Tool, Toolkit } from "effect/unstable/ai"
|
|
import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
|
|
describe("OpenAiLanguageModel", () => {
|
|
describe("generateText", () => {
|
|
it.effect("sends model in request and decodes text output", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: "Hello, compat!"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.strictEqual(result.text, "Hello, compat!")
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
assert.strictEqual(requestBody.model, "gpt-4o-mini")
|
|
assert.strictEqual(requestBody.messages[0]?.content, "hello")
|
|
}))
|
|
|
|
it.effect("forwards reasoning config to chat completions request", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: "Done"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-5", {
|
|
reasoning: {
|
|
effort: "medium",
|
|
summary: "auto"
|
|
}
|
|
})),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
assert.deepStrictEqual(requestBody.reasoning, {
|
|
effort: "medium",
|
|
summary: "auto"
|
|
})
|
|
}))
|
|
|
|
it.effect("forwards custom model config properties to chat completions request", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: "Done"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini", {
|
|
vendor_setting: {
|
|
mode: "strict"
|
|
}
|
|
})),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
assert.deepStrictEqual(requestBody.vendor_setting, {
|
|
mode: "strict"
|
|
})
|
|
}))
|
|
|
|
it.effect("preserves multimodal user content order in chat payload", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: "done"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
yield* LanguageModel.generateText({
|
|
prompt: Prompt.make([{
|
|
role: "user",
|
|
content: [
|
|
Prompt.textPart({ text: "first text" }),
|
|
Prompt.filePart({
|
|
mediaType: "image/png",
|
|
data: new URL("https://example.com/image.png")
|
|
}),
|
|
Prompt.textPart({ text: "second text" })
|
|
]
|
|
}])
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
const content = requestBody.messages[0]?.content
|
|
assert.isTrue(Array.isArray(content))
|
|
assert.deepStrictEqual(content, [
|
|
{
|
|
type: "text",
|
|
text: "first text"
|
|
},
|
|
{
|
|
type: "image_url",
|
|
image_url: {
|
|
url: "https://example.com/image.png",
|
|
detail: "auto"
|
|
}
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "second text"
|
|
}
|
|
])
|
|
}))
|
|
|
|
it.effect("normalizes empty assistant message content to an empty string", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: "done"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
yield* LanguageModel.generateText({
|
|
prompt: Prompt.make([
|
|
{ role: "user", content: "hello" },
|
|
{
|
|
role: "assistant",
|
|
content: [Prompt.textPart({ text: "" })]
|
|
},
|
|
{ role: "user", content: "continue" }
|
|
])
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
const assistantMessage = requestBody.messages.find((message: any) => message.role === "assistant")
|
|
assert.isDefined(assistantMessage)
|
|
assert.strictEqual(assistantMessage.content, "")
|
|
assert.isUndefined(assistantMessage.tool_calls)
|
|
}))
|
|
|
|
it.effect("maps function_call output to tool-call part and sends function tool schema", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "tool_calls",
|
|
message: {
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: {
|
|
name: "TestTool",
|
|
arguments: JSON.stringify({ input: "hello" })
|
|
}
|
|
}]
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
const result = yield* LanguageModel.generateText({
|
|
prompt: "use the tool",
|
|
toolkit: TestToolkit,
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(TestToolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const toolCall = result.content.find((part) => part.type === "tool-call")
|
|
assert.isDefined(toolCall)
|
|
if (toolCall?.type !== "tool-call") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(toolCall.name, "TestTool")
|
|
assert.deepStrictEqual(toolCall.params, { input: "hello" })
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
const functionTool = requestBody.tools.find((tool: any) => tool.type === "function")
|
|
assert.isDefined(functionTool)
|
|
assert.strictEqual(functionTool.function.name, "TestTool")
|
|
assert.strictEqual(functionTool.function.strict, true)
|
|
}))
|
|
|
|
it.effect("converts dynamic tools to function type", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: "Done"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
const inputSchema = {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string" },
|
|
limit: { type: "number" }
|
|
},
|
|
required: ["query"],
|
|
additionalProperties: false
|
|
} as const
|
|
|
|
const DynamicTool = Tool.dynamic("DynamicTool", {
|
|
description: "A dynamic tool",
|
|
parameters: inputSchema
|
|
})
|
|
|
|
yield* LanguageModel.generateText({
|
|
prompt: "use dynamic tool",
|
|
toolkit: Toolkit.make(DynamicTool),
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
const functionTool = requestBody.tools?.find((tool: any) =>
|
|
tool.type === "function" && tool.function?.name === "DynamicTool"
|
|
)
|
|
assert.isDefined(functionTool)
|
|
assert.strictEqual(functionTool.function.description, "A dynamic tool")
|
|
assert.deepStrictEqual(functionTool.function.parameters, inputSchema)
|
|
}))
|
|
|
|
it.effect("maps provider apply_patch function call back to custom provider-defined tool", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "tool_calls",
|
|
message: {
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: {
|
|
name: "apply_patch",
|
|
arguments: JSON.stringify({
|
|
call_id: "call_1",
|
|
operation: {
|
|
type: "delete_file",
|
|
path: "src/obsolete.ts"
|
|
}
|
|
})
|
|
}
|
|
}]
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
const toolkit = Toolkit.make(CompatApplyPatchTool({}))
|
|
const toolkitLayer = toolkit.toLayer({
|
|
CompatApplyPatch: () =>
|
|
Effect.succeed({
|
|
status: "completed",
|
|
output: "deleted"
|
|
})
|
|
})
|
|
|
|
const result = yield* LanguageModel.generateText({
|
|
prompt: "delete src/obsolete.ts",
|
|
toolkit,
|
|
toolChoice: { tool: "CompatApplyPatch" },
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(toolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const toolCall = result.content.find((part) => part.type === "tool-call")
|
|
assert.isDefined(toolCall)
|
|
if (toolCall?.type !== "tool-call") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(toolCall.name, "CompatApplyPatch")
|
|
assert.deepStrictEqual(toolCall.params, {
|
|
call_id: "call_1",
|
|
operation: {
|
|
type: "delete_file",
|
|
path: "src/obsolete.ts"
|
|
}
|
|
})
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
|
|
const functionTool = requestBody.tools.find((tool: any) => tool.type === "function")
|
|
assert.isDefined(functionTool)
|
|
assert.strictEqual(functionTool.function.name, "apply_patch")
|
|
assert.deepStrictEqual(requestBody.tool_choice, {
|
|
type: "function",
|
|
function: {
|
|
name: "apply_patch"
|
|
}
|
|
})
|
|
}))
|
|
|
|
it.effect("decodes usage when token detail fields are absent", () =>
|
|
Effect.gen(function*() {
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: "Hello"
|
|
}
|
|
}],
|
|
usage: {
|
|
prompt_tokens: 4,
|
|
completion_tokens: 5,
|
|
total_tokens: 9,
|
|
provider_future_field: true
|
|
}
|
|
})
|
|
))
|
|
)
|
|
))
|
|
)
|
|
|
|
const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const finish = result.content.find((part) => part.type === "finish")
|
|
assert.isDefined(finish)
|
|
if (finish?.type !== "finish") {
|
|
return
|
|
}
|
|
|
|
assert.deepStrictEqual(finish.usage.inputTokens, {
|
|
uncached: 4,
|
|
total: 4,
|
|
cacheRead: 0,
|
|
cacheWrite: undefined
|
|
})
|
|
assert.deepStrictEqual(finish.usage.outputTokens, {
|
|
total: 5,
|
|
text: 5,
|
|
reasoning: 0
|
|
})
|
|
}))
|
|
|
|
it.effect("surfaces reasoning from non-streaming responses", () =>
|
|
Effect.gen(function*() {
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
reasoning: "I should greet the user.",
|
|
content: "Hello!"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
)
|
|
))
|
|
)
|
|
|
|
const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const reasoning = result.content.find((part) => part.type === "reasoning")
|
|
assert.isDefined(reasoning)
|
|
if (reasoning?.type !== "reasoning") {
|
|
return
|
|
}
|
|
assert.strictEqual(reasoning.text, "I should greet the user.")
|
|
assert.strictEqual(result.text, "Hello!")
|
|
}))
|
|
|
|
it.effect("surfaces reasoning_content from non-streaming responses", () =>
|
|
Effect.gen(function*() {
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
reasoning_content: "I should greet the user.",
|
|
content: "Hello!"
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
)
|
|
))
|
|
)
|
|
|
|
const result = yield* LanguageModel.generateText({ prompt: "hello" }).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const reasoning = result.content.find((part) => part.type === "reasoning")
|
|
assert.isDefined(reasoning)
|
|
if (reasoning?.type !== "reasoning") {
|
|
return
|
|
}
|
|
assert.strictEqual(reasoning.text, "I should greet the user.")
|
|
assert.strictEqual(result.text, "Hello!")
|
|
}))
|
|
})
|
|
|
|
describe("generateObject", () => {
|
|
it.effect("uses json_schema format for structured output", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: JSON.stringify({ name: "Ada", age: 37 })
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
const person = yield* LanguageModel.generateObject({
|
|
prompt: "Return a person",
|
|
schema: Schema.Struct({
|
|
name: Schema.String,
|
|
age: Schema.Number
|
|
})
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.strictEqual(person.value.name, "Ada")
|
|
assert.strictEqual(person.value.age, 37)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
assert.strictEqual(requestBody.response_format.type, "json_schema")
|
|
assert.strictEqual(requestBody.response_format.json_schema.strict, true)
|
|
}))
|
|
|
|
it.effect("uses OpenAI codec transformer for optional structured fields", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(
|
|
request,
|
|
makeChatCompletion({
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: JSON.stringify({ name: "Ada", nickname: null })
|
|
}
|
|
}]
|
|
})
|
|
))
|
|
})
|
|
))
|
|
)
|
|
|
|
const person = yield* LanguageModel.generateObject({
|
|
prompt: "Return a person",
|
|
schema: Schema.Struct({
|
|
name: Schema.String,
|
|
nickname: Schema.optionalKey(Schema.String)
|
|
})
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.strictEqual(person.value.name, "Ada")
|
|
assert.isUndefined(person.value.nickname)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
assert.deepStrictEqual(requestBody.response_format.json_schema.schema.required, ["name", "nickname"])
|
|
assert.deepStrictEqual(requestBody.response_format.json_schema.schema.properties.nickname, {
|
|
anyOf: [{ type: "string" }, { type: "null" }]
|
|
})
|
|
}))
|
|
})
|
|
|
|
describe("streamText", () => {
|
|
it.effect("handles chat completion stream chunks", () =>
|
|
Effect.gen(function*() {
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
{
|
|
id: "chatcmpl_test123",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: "stop"
|
|
}]
|
|
},
|
|
"[DONE]"
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = Array.from(partsChunk)
|
|
|
|
assert.isTrue(parts.some((part) => part.type === "response-metadata"))
|
|
|
|
const finish = parts.find((part) => part.type === "finish")
|
|
assert.isDefined(finish)
|
|
if (finish?.type === "finish") {
|
|
assert.strictEqual(finish.reason, "stop")
|
|
}
|
|
}))
|
|
|
|
it.effect("maps local shell stream tool calls to local_shell call outputs", () =>
|
|
Effect.gen(function*() {
|
|
const capturedRequests = yield* Ref.make<ReadonlyArray<HttpClientRequest.HttpClientRequest>>([])
|
|
const requestCount = yield* Ref.make(0)
|
|
|
|
const httpClient = HttpClient.makeWith(
|
|
Effect.fnUntraced(function*(requestEffect) {
|
|
const request = yield* requestEffect
|
|
yield* Ref.update(capturedRequests, (requests) => [...requests, request])
|
|
const index = yield* Ref.getAndUpdate(requestCount, (value) => value + 1)
|
|
|
|
if (index === 0) {
|
|
return sseResponse(request, [makeLocalShellChunk(), "[DONE]"])
|
|
}
|
|
|
|
return jsonResponse(request, makeChatCompletion())
|
|
}),
|
|
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
|
|
)
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(HttpClient.HttpClient, httpClient))
|
|
)
|
|
|
|
const toolkit = Toolkit.make(CompatLocalShellTool({}))
|
|
const toolkitLayer = toolkit.toLayer({
|
|
CompatLocalShell: () => Effect.succeed("done")
|
|
})
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({
|
|
prompt: "Run pwd",
|
|
toolkit,
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(toolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const toolCall = globalThis.Array.from(partsChunk).find((part) => part.type === "tool-call")
|
|
assert.isDefined(toolCall)
|
|
if (toolCall?.type !== "tool-call") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(toolCall.name, "CompatLocalShell")
|
|
assert.deepStrictEqual(toolCall.params, { action: localShellAction })
|
|
|
|
yield* LanguageModel.generateText({
|
|
prompt: Prompt.make([
|
|
{ role: "user", content: "Run pwd" },
|
|
{
|
|
role: "assistant",
|
|
content: [Prompt.toolCallPart({
|
|
id: toolCall.id,
|
|
name: toolCall.name,
|
|
params: { action: localShellAction },
|
|
providerExecuted: false,
|
|
options: {
|
|
openai: {
|
|
itemId: "ls_call_1"
|
|
}
|
|
}
|
|
})]
|
|
},
|
|
{
|
|
role: "tool",
|
|
content: [Prompt.toolResultPart({
|
|
id: toolCall.id,
|
|
name: toolCall.name,
|
|
isFailure: false,
|
|
result: "done"
|
|
})]
|
|
}
|
|
]),
|
|
toolkit
|
|
}).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(toolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const requests = yield* Ref.get(capturedRequests)
|
|
const followUpRequest = requests[1]
|
|
assert.isDefined(followUpRequest)
|
|
if (followUpRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const followUpBody = yield* getRequestBody(followUpRequest)
|
|
|
|
const localShellCall = followUpBody.messages.find((item: any) =>
|
|
item.role === "assistant" && item.tool_calls?.[0]?.function?.name === "local_shell"
|
|
)
|
|
assert.isDefined(localShellCall)
|
|
assert.strictEqual(localShellCall.content, null)
|
|
assert.strictEqual(localShellCall.tool_calls[0].id, toolCall.id)
|
|
|
|
const localShellOutput = followUpBody.messages.find((item: any) => item.role === "tool")
|
|
assert.isDefined(localShellOutput)
|
|
assert.strictEqual(localShellOutput.tool_call_id, toolCall.id)
|
|
assert.strictEqual(localShellOutput.content, "done")
|
|
}))
|
|
|
|
it.effect("maps apply_patch stream tool calls to custom provider-defined tool", () =>
|
|
Effect.gen(function*() {
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
{
|
|
id: "chatcmpl_apply_patch_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
id: "patch_call_1",
|
|
type: "function",
|
|
function: {
|
|
name: "apply_patch",
|
|
arguments: JSON.stringify({
|
|
call_id: "patch_call_1",
|
|
operation: {
|
|
type: "delete_file",
|
|
path: "src/legacy.ts"
|
|
}
|
|
})
|
|
}
|
|
}]
|
|
},
|
|
finish_reason: "tool_calls"
|
|
}]
|
|
},
|
|
"[DONE]"
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const toolkit = Toolkit.make(CompatApplyPatchTool({}))
|
|
const toolkitLayer = toolkit.toLayer({
|
|
CompatApplyPatch: () =>
|
|
Effect.succeed({
|
|
status: "completed",
|
|
output: "deleted"
|
|
})
|
|
})
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({
|
|
prompt: "Delete src/legacy.ts",
|
|
toolkit,
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(toolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const toolCall = globalThis.Array.from(partsChunk).find((part) => part.type === "tool-call")
|
|
assert.isDefined(toolCall)
|
|
if (toolCall?.type !== "tool-call") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(toolCall.name, "CompatApplyPatch")
|
|
assert.deepStrictEqual(toolCall.params, {
|
|
call_id: "patch_call_1",
|
|
operation: {
|
|
type: "delete_file",
|
|
path: "src/legacy.ts"
|
|
}
|
|
})
|
|
}))
|
|
|
|
it.effect("preserves fragmented stream tool call ids and names", () =>
|
|
Effect.gen(function*() {
|
|
const expectedParams = {
|
|
call_id: "patch_call_2",
|
|
operation: {
|
|
type: "delete_file",
|
|
path: "src/fragmented.ts"
|
|
}
|
|
}
|
|
const toolArguments = JSON.stringify(expectedParams)
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
{
|
|
id: "chatcmpl_apply_patch_fragmented_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
id: "patch_call_2",
|
|
type: "function",
|
|
function: {
|
|
name: "apply_patch",
|
|
arguments: toolArguments.slice(0, 24)
|
|
}
|
|
}]
|
|
},
|
|
finish_reason: null
|
|
}]
|
|
},
|
|
{
|
|
id: "chatcmpl_apply_patch_fragmented_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
function: {
|
|
arguments: toolArguments.slice(24, 48)
|
|
}
|
|
}]
|
|
},
|
|
finish_reason: null
|
|
}]
|
|
},
|
|
{
|
|
id: "chatcmpl_apply_patch_fragmented_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
function: {
|
|
arguments: toolArguments.slice(48)
|
|
}
|
|
}]
|
|
},
|
|
finish_reason: "tool_calls"
|
|
}]
|
|
},
|
|
"[DONE]"
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const toolkit = Toolkit.make(CompatApplyPatchTool({}))
|
|
const toolkitLayer = toolkit.toLayer({
|
|
CompatApplyPatch: () =>
|
|
Effect.succeed({
|
|
status: "completed",
|
|
output: "deleted"
|
|
})
|
|
})
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({
|
|
prompt: "Delete src/fragmented.ts",
|
|
toolkit,
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(toolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
|
|
const start = parts.find((part) => part.type === "tool-params-start" && part.id === "patch_call_2")
|
|
assert.isDefined(start)
|
|
if (start?.type !== "tool-params-start") {
|
|
return
|
|
}
|
|
assert.strictEqual(start.name, "CompatApplyPatch")
|
|
|
|
assert.deepStrictEqual(decodeToolParamsFromStream(parts, "patch_call_2"), expectedParams)
|
|
|
|
const toolCall = parts.find((part) => part.type === "tool-call")
|
|
assert.isDefined(toolCall)
|
|
if (toolCall?.type !== "tool-call") {
|
|
return
|
|
}
|
|
|
|
assert.strictEqual(toolCall.id, "patch_call_2")
|
|
assert.strictEqual(toolCall.name, "CompatApplyPatch")
|
|
assert.deepStrictEqual(toolCall.params, expectedParams)
|
|
}))
|
|
|
|
it.effect("streams known events and ignores unknown ones", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const events = [
|
|
{
|
|
id: "chatcmpl_stream_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: { content: "Hello" },
|
|
finish_reason: null
|
|
}]
|
|
},
|
|
{
|
|
id: "chatcmpl_stream_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: "stop"
|
|
}],
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 7,
|
|
total_tokens: 17,
|
|
prompt_tokens_details: {
|
|
cached_tokens: 3
|
|
},
|
|
completion_tokens_details: {
|
|
reasoning_tokens: 2
|
|
}
|
|
},
|
|
provider_future_field: { accepted: true }
|
|
},
|
|
"[DONE]"
|
|
]
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(sseResponse(request, events))
|
|
})
|
|
))
|
|
)
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({ prompt: "hello" }).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
const metadata = parts.find((part) => part.type === "response-metadata")
|
|
const finish = parts.find((part) => part.type === "finish")
|
|
const deltas = parts.filter((part) => part.type === "text-delta")
|
|
|
|
assert.isDefined(metadata)
|
|
assert.isDefined(finish)
|
|
assert.strictEqual(deltas.length, 1)
|
|
assert.strictEqual(deltas[0]?.delta, "Hello")
|
|
if (finish?.type === "finish") {
|
|
assert.strictEqual(finish.reason, "stop")
|
|
assert.deepStrictEqual(finish.usage.inputTokens, {
|
|
uncached: 7,
|
|
total: 10,
|
|
cacheRead: 3,
|
|
cacheWrite: undefined
|
|
})
|
|
assert.deepStrictEqual(finish.usage.outputTokens, {
|
|
total: 7,
|
|
text: 5,
|
|
reasoning: 2
|
|
})
|
|
}
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) {
|
|
return
|
|
}
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
assert.strictEqual(requestBody.stream, true)
|
|
assert.isTrue(capturedRequest.url.endsWith("/chat/completions"))
|
|
}))
|
|
|
|
it.effect("assembles streamed tool args when continuation fragments have function.name: null", () =>
|
|
Effect.gen(function*() {
|
|
// Some OpenAI-compatible providers (e.g. Fireworks) only send the tool
|
|
// name on the first fragment and `function.name: null` on every
|
|
// continuation. The argument fragments live on those continuations, so
|
|
// they must not be dropped during chunk validation.
|
|
const chunk = (fnDelta: Record<string, unknown>) => ({
|
|
id: "chatcmpl_null_name_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: fnDelta }] }
|
|
}]
|
|
})
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
chunk({ name: "TestTool", arguments: "" }),
|
|
chunk({ name: null, arguments: "{\"in" }),
|
|
chunk({ name: null, arguments: "put\":\"hel" }),
|
|
chunk({ name: null, arguments: "lo\"}" }),
|
|
{
|
|
id: "chatcmpl_null_name_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }]
|
|
},
|
|
"[DONE]"
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({
|
|
prompt: "use the tool",
|
|
toolkit: TestToolkit,
|
|
disableToolCallResolution: true
|
|
}).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(TestToolkitLayer),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
const paramsDeltas = parts.filter((part) => part.type === "tool-params-delta")
|
|
assert.isAbove(paramsDeltas.length, 0)
|
|
|
|
const toolCall = parts.find((part) => part.type === "tool-call")
|
|
assert.isDefined(toolCall)
|
|
if (toolCall?.type !== "tool-call") {
|
|
return
|
|
}
|
|
assert.strictEqual(toolCall.name, "TestTool")
|
|
assert.deepStrictEqual(toolCall.params, { input: "hello" })
|
|
}))
|
|
|
|
it.effect("emits reasoning lifecycle parts for delta.reasoning", () =>
|
|
Effect.gen(function*() {
|
|
const chunk = (delta: Record<string, unknown>, finishReason: string | null = null) => ({
|
|
id: "chatcmpl_reasoning_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
|
})
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
chunk({ reasoning: "Let me think" }),
|
|
chunk({ reasoning: " about this." }),
|
|
chunk({ content: "Hello" }),
|
|
chunk({ content: " there" }, "stop"),
|
|
"[DONE]"
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
assert.deepStrictEqual(parts.map((part) => part.type), [
|
|
"response-metadata",
|
|
"reasoning-start",
|
|
"reasoning-delta",
|
|
"reasoning-delta",
|
|
"reasoning-end",
|
|
"text-start",
|
|
"text-delta",
|
|
"text-delta",
|
|
"text-end",
|
|
"finish"
|
|
])
|
|
|
|
const reasoningText = parts
|
|
.flatMap((part) => part.type === "reasoning-delta" ? [part.delta] : [])
|
|
.join("")
|
|
assert.strictEqual(reasoningText, "Let me think about this.")
|
|
|
|
const reasoningIds = parts.flatMap((part) =>
|
|
part.type === "reasoning-start" || part.type === "reasoning-delta" || part.type === "reasoning-end"
|
|
? [part.id]
|
|
: []
|
|
)
|
|
assert.strictEqual(new Set(reasoningIds).size, 1)
|
|
|
|
const textIds = parts.flatMap((part) => part.type === "text-start" ? [part.id] : [])
|
|
assert.notStrictEqual(reasoningIds[0], textIds[0])
|
|
}))
|
|
|
|
it.effect("emits reasoning lifecycle parts for delta.reasoning_content", () =>
|
|
Effect.gen(function*() {
|
|
const chunk = (delta: Record<string, unknown>, finishReason: string | null = null) => ({
|
|
id: "chatcmpl_reasoning_2",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
|
})
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
chunk({ reasoning_content: "Thinking..." }),
|
|
chunk({ content: "Answer." }, "stop"),
|
|
"[DONE]"
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
assert.deepStrictEqual(parts.map((part) => part.type), [
|
|
"response-metadata",
|
|
"reasoning-start",
|
|
"reasoning-delta",
|
|
"reasoning-end",
|
|
"text-start",
|
|
"text-delta",
|
|
"text-end",
|
|
"finish"
|
|
])
|
|
|
|
const reasoningText = parts
|
|
.flatMap((part) => part.type === "reasoning-delta" ? [part.delta] : [])
|
|
.join("")
|
|
assert.strictEqual(reasoningText, "Thinking...")
|
|
}))
|
|
|
|
it.effect("closes an open reasoning part at stream end", () =>
|
|
Effect.gen(function*() {
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) =>
|
|
Effect.succeed(sseResponse(request, [
|
|
{
|
|
id: "chatcmpl_reasoning_3",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{ index: 0, delta: { reasoning: "Only thoughts." }, finish_reason: "stop" }]
|
|
},
|
|
"[DONE]"
|
|
]))
|
|
)
|
|
))
|
|
)
|
|
|
|
const partsChunk = yield* LanguageModel.streamText({ prompt: "test" }).pipe(
|
|
Stream.runCollect,
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
const parts = globalThis.Array.from(partsChunk)
|
|
assert.deepStrictEqual(parts.map((part) => part.type), [
|
|
"response-metadata",
|
|
"reasoning-start",
|
|
"reasoning-delta",
|
|
"reasoning-end",
|
|
"finish"
|
|
])
|
|
}))
|
|
})
|
|
|
|
describe("config", () => {
|
|
it.effect("does not leak library-only fields into request body", () =>
|
|
Effect.gen(function*() {
|
|
let capturedRequest: HttpClientRequest.HttpClientRequest | undefined
|
|
|
|
const layer = OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
|
|
Layer.provide(Layer.succeed(
|
|
HttpClient.HttpClient,
|
|
makeHttpClient((request) => {
|
|
capturedRequest = request
|
|
return Effect.succeed(jsonResponse(request, makeChatCompletion()))
|
|
})
|
|
))
|
|
)
|
|
|
|
yield* LanguageModel.generateText({ prompt: "test" }).pipe(
|
|
Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini", {
|
|
fileIdPrefixes: ["file-"],
|
|
strictJsonSchema: false,
|
|
temperature: 0.5
|
|
})),
|
|
Effect.provide(layer)
|
|
)
|
|
|
|
assert.isDefined(capturedRequest)
|
|
if (capturedRequest === undefined) return
|
|
|
|
const requestBody = yield* getRequestBody(capturedRequest)
|
|
assert.strictEqual(requestBody.fileIdPrefixes, undefined)
|
|
assert.strictEqual(requestBody.strictJsonSchema, undefined)
|
|
assert.strictEqual(requestBody.temperature, 0.5)
|
|
}))
|
|
})
|
|
})
|
|
|
|
const TestTool = Tool.make("TestTool", {
|
|
description: "A test tool",
|
|
parameters: Schema.Struct({ input: Schema.String }),
|
|
success: Schema.Struct({ output: Schema.String })
|
|
})
|
|
|
|
const TestToolkit = Toolkit.make(TestTool)
|
|
|
|
const TestToolkitLayer = TestToolkit.toLayer({
|
|
TestTool: ({ input }) => Effect.succeed({ output: input })
|
|
})
|
|
|
|
const CompatApplyPatchTool = Tool.providerDefined({
|
|
id: "compat.apply_patch",
|
|
customName: "CompatApplyPatch",
|
|
providerName: "apply_patch",
|
|
requiresHandler: true,
|
|
parameters: Schema.Struct({
|
|
call_id: Schema.String,
|
|
operation: Schema.Any
|
|
}),
|
|
success: Schema.Struct({
|
|
status: Schema.Literals(["completed", "failed"]),
|
|
output: Schema.optionalKey(Schema.NullOr(Schema.String))
|
|
})
|
|
})
|
|
|
|
const localShellAction = {
|
|
type: "exec",
|
|
command: ["pwd"],
|
|
env: {}
|
|
}
|
|
|
|
const CompatLocalShellTool = Tool.providerDefined({
|
|
id: "compat.local_shell",
|
|
customName: "CompatLocalShell",
|
|
providerName: "local_shell",
|
|
requiresHandler: true,
|
|
parameters: Schema.Struct({
|
|
action: Schema.Any
|
|
}),
|
|
success: Schema.String
|
|
})
|
|
|
|
const makeHttpClient = (
|
|
handler: (
|
|
request: HttpClientRequest.HttpClientRequest
|
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
|
|
) =>
|
|
HttpClient.makeWith(
|
|
Effect.fnUntraced(function*(requestEffect) {
|
|
const request = yield* requestEffect
|
|
return yield* handler(request)
|
|
}),
|
|
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
|
|
)
|
|
|
|
const makeChatCompletion = (overrides: Record<string, unknown> = {}) => ({
|
|
id: "chatcmpl_test_1",
|
|
object: "chat.completion",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
finish_reason: "stop",
|
|
message: {
|
|
role: "assistant",
|
|
content: ""
|
|
}
|
|
}],
|
|
...overrides
|
|
})
|
|
|
|
const makeLocalShellChunk = () => ({
|
|
id: "chatcmpl_local_shell_1",
|
|
object: "chat.completion.chunk",
|
|
model: "gpt-4o-mini",
|
|
created: 1,
|
|
choices: [{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [{
|
|
index: 0,
|
|
id: "local_shell_call_1",
|
|
type: "function",
|
|
function: {
|
|
name: "local_shell",
|
|
arguments: JSON.stringify({ action: localShellAction })
|
|
}
|
|
}]
|
|
},
|
|
finish_reason: "tool_calls"
|
|
}]
|
|
})
|
|
|
|
const jsonResponse = (
|
|
request: HttpClientRequest.HttpClientRequest,
|
|
body: unknown
|
|
): HttpClientResponse.HttpClientResponse =>
|
|
HttpClientResponse.fromWeb(
|
|
request,
|
|
new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/json"
|
|
}
|
|
})
|
|
)
|
|
|
|
const sseResponse = (
|
|
request: HttpClientRequest.HttpClientRequest,
|
|
events: ReadonlyArray<unknown>
|
|
): HttpClientResponse.HttpClientResponse =>
|
|
HttpClientResponse.fromWeb(
|
|
request,
|
|
new Response(toSseBody(events), {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/event-stream"
|
|
}
|
|
})
|
|
)
|
|
|
|
const getRequestBody = (request: HttpClientRequest.HttpClientRequest) =>
|
|
Effect.gen(function*() {
|
|
const body = request.body
|
|
if (body._tag === "Uint8Array") {
|
|
const text = new TextDecoder().decode(body.body)
|
|
return JSON.parse(text)
|
|
}
|
|
return yield* Effect.die(new Error("Expected Uint8Array body"))
|
|
})
|
|
|
|
const decodeToolParamsFromStream = (
|
|
parts: ReadonlyArray<any>,
|
|
toolCallId: string
|
|
): Record<string, unknown> => {
|
|
const start = parts.find((part) => part.type === "tool-params-start" && part.id === toolCallId)
|
|
const end = parts.find((part) => part.type === "tool-params-end" && part.id === toolCallId)
|
|
assert.isDefined(start)
|
|
assert.isDefined(end)
|
|
|
|
const deltas = parts
|
|
.filter((part) => part.type === "tool-params-delta" && part.id === toolCallId)
|
|
.map((part) => part.delta)
|
|
.join("")
|
|
|
|
return JSON.parse(deltas) as Record<string, unknown>
|
|
}
|
|
|
|
const toSseBody = (events: ReadonlyArray<unknown>): string =>
|
|
events.map((event) => {
|
|
if (typeof event === "string") {
|
|
return `data: ${event}\n\n`
|
|
}
|
|
return `data: ${JSON.stringify(event)}\n\n`
|
|
}).join("")
|