|
| 1 | +import { Effect, Schema } from "effect" |
| 2 | +import type { HttpClientResponse } from "effect/http" |
| 3 | +import { |
| 4 | + EvaluationModel, |
| 5 | + EvaluationResponse, |
| 6 | + EvaluationRounding, |
| 7 | + type EvaluationAnswer, |
| 8 | + type EvaluationInput, |
| 9 | + type EvaluationRequestFor, |
| 10 | +} from "./evaluation.js" |
| 11 | +import { OpenResponses } from "../protocols/open-responses.js" |
| 12 | +import { Endpoint } from "../route/endpoint.js" |
| 13 | +import { MediaProtocol } from "../route/media-protocol.js" |
| 14 | +import { MediaRoute } from "../route/media.js" |
| 15 | +import { ModelID, mergeJsonRecords, type OpenString } from "../schema/index.js" |
| 16 | +import { encodeJson } from "../utils/json.js" |
| 17 | + |
| 18 | +const route = MediaProtocol.identity({ id: "openai-decisions", name: "OpenAI Decisions", provider: "openai" }) |
| 19 | +export const DEFAULT_BASE_URL = "https://api.openai.com/v1" |
| 20 | +export const PATH = "/decisions" |
| 21 | + |
| 22 | +/** Native body fields, sent as given. */ |
| 23 | +export type OpenAIDecisionOptions = { |
| 24 | + readonly prompt_cache_key?: string |
| 25 | + readonly prompt_cache_options?: Readonly<{ mode?: OpenString<"implicit" | "explicit">; ttl?: OpenString<"30m"> }> |
| 26 | + readonly safety_identifier?: string |
| 27 | +} & Record<string, unknown> |
| 28 | + |
| 29 | +type Request = EvaluationRequestFor<OpenAIDecisionOptions> |
| 30 | + |
| 31 | +const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 })) |
| 32 | +const Answer = Schema.Union([ |
| 33 | + Schema.Struct({ type: Schema.Literal("predicate"), name: Schema.String, probability: Probability }), |
| 34 | + Schema.Struct({ |
| 35 | + type: Schema.Literal("choice"), |
| 36 | + name: Schema.String, |
| 37 | + choice: Schema.String, |
| 38 | + probabilities: Schema.Array(Schema.Struct({ value: Schema.String, probability: Probability })), |
| 39 | + confidence: Schema.optional(Probability), |
| 40 | + }), |
| 41 | + Schema.Struct({ |
| 42 | + type: Schema.Literal("score"), |
| 43 | + name: Schema.String, |
| 44 | + score: Schema.Number, |
| 45 | + probabilities: Schema.Array(Schema.Struct({ value: Schema.Int, probability: Probability })), |
| 46 | + confidence: Schema.optional(Probability), |
| 47 | + }), |
| 48 | + Schema.Struct({ type: Schema.Literal("refusal"), name: Schema.String }), |
| 49 | +]) |
| 50 | +const Response = Schema.Struct({ |
| 51 | + model: Schema.String, |
| 52 | + answers: Schema.Array(Answer), |
| 53 | + usage: Schema.optional(OpenResponses.OpenResponsesUsage), |
| 54 | +}) |
| 55 | + |
| 56 | +const fromRequest = Effect.fn("OpenAIDecisions.fromRequest")(function* (request: Request) { |
| 57 | + const criteria = Object.entries(request.questions).flatMap(([name, question]) => |
| 58 | + question.type === "boolean" && question.criteria !== undefined ? [name] : [], |
| 59 | + ) |
| 60 | + if (criteria.length > 0) |
| 61 | + return yield* route.unsupported( |
| 62 | + "evaluation.criteria", |
| 63 | + `${route.name} boolean questions take instructions only; remove criteria from ${criteria.join(", ")}`, |
| 64 | + ) |
| 65 | + return MediaProtocol.json({ |
| 66 | + ...mergeJsonRecords(request.options, request.http?.body), |
| 67 | + model: request.model.id, |
| 68 | + input: typeof request.state === "string" ? request.state : [{ role: "user", content: json(request.state) }], |
| 69 | + questions: Object.entries(request.questions).map(([name, question]) => { |
| 70 | + const instructions = typeof question.instructions === "string" ? question.instructions : json(question.instructions) |
| 71 | + if (question.type === "boolean") return { type: "predicate", name, instructions } |
| 72 | + if (question.type === "choice") |
| 73 | + return { |
| 74 | + type: "choice", |
| 75 | + name, |
| 76 | + instructions, |
| 77 | + choices: Object.entries(question.criteria).map(([value, description]) => ({ |
| 78 | + value, |
| 79 | + ...(description === null ? {} : { description: text(description) }), |
| 80 | + })), |
| 81 | + } |
| 82 | + return { |
| 83 | + type: "score", |
| 84 | + name, |
| 85 | + instructions, |
| 86 | + levels: question.criteria.map((level, index) => |
| 87 | + typeof level === "string" |
| 88 | + ? { label: level } |
| 89 | + : { label: String(index), ...(level === null ? {} : { description: text(level) }) }, |
| 90 | + ), |
| 91 | + } |
| 92 | + }), |
| 93 | + }) |
| 94 | +}) |
| 95 | + |
| 96 | +const json = (data: EvaluationInput) => [{ type: "input_json", data }] |
| 97 | +const text = (value: EvaluationInput) => (typeof value === "string" ? value : encodeJson(value)) |
| 98 | + |
| 99 | +const decodeDocument = route.decodeJson(Response) |
| 100 | + |
| 101 | +const decodeResponse = Effect.fn("OpenAIDecisions.decodeResponse")(function* ( |
| 102 | + response: HttpClientResponse.HttpClientResponse, |
| 103 | +) { |
| 104 | + const output = yield* decodeDocument(response) |
| 105 | + const refused = output.value.answers.flatMap((answer) => (answer.type === "refusal" ? [answer.name] : [])) |
| 106 | + if (refused.length > 0) return yield* output.contentPolicy(`${route.name} refused to answer ${refused.join(", ")}`) |
| 107 | + return new EvaluationResponse({ |
| 108 | + model: ModelID.make(output.value.model), |
| 109 | + answers: Object.fromEntries( |
| 110 | + output.value.answers.flatMap((answer): Array<[string, EvaluationAnswer]> => { |
| 111 | + if (answer.type === "refusal") return [] |
| 112 | + if (answer.type === "predicate") return [[answer.name, { type: "boolean", probability: answer.probability }]] |
| 113 | + const probabilities = Object.fromEntries(answer.probabilities.map((item) => [item.value, item.probability])) |
| 114 | + const confidence = answer.confidence === undefined ? {} : { confidence: answer.confidence } |
| 115 | + if (answer.type === "choice") |
| 116 | + return [[answer.name, { type: "choice", choice: answer.choice, probabilities, ...confidence }]] |
| 117 | + return [[answer.name, { type: "score", score: answer.score, probabilities, ...confidence }]] |
| 118 | + }), |
| 119 | + ), |
| 120 | + usage: OpenResponses.mapUsage(output.value.usage, "openai"), |
| 121 | + rounding: new EvaluationRounding({ probabilityDecimals: 2, scoreDecimals: 2 }), |
| 122 | + }) |
| 123 | +}) |
| 124 | + |
| 125 | +export const protocol = MediaProtocol.inline<Request, EvaluationResponse>(route, { |
| 126 | + body: { from: fromRequest }, |
| 127 | + response: { decode: decodeResponse }, |
| 128 | +}) |
| 129 | + |
| 130 | +export const model = (input: MediaRoute.ModelInput) => { |
| 131 | + const composed = MediaRoute.inline({ |
| 132 | + protocol, |
| 133 | + endpoint: Endpoint.path<MediaProtocol.Body, Request>(PATH, { baseURL: input.baseURL ?? DEFAULT_BASE_URL }), |
| 134 | + auth: input.auth, |
| 135 | + headers: input.headers, |
| 136 | + }) |
| 137 | + return EvaluationModel.make<OpenAIDecisionOptions>({ |
| 138 | + id: input.id, |
| 139 | + provider: protocol.provider, |
| 140 | + http: input.http, |
| 141 | + route: { id: composed.id, evaluate: composed.generate }, |
| 142 | + }) |
| 143 | +} |
| 144 | + |
| 145 | +export const OpenAIDecisions = { |
| 146 | + protocol, |
| 147 | + model, |
| 148 | +} as const |
0 commit comments