1/**
2 * Decision models backed by OpenRouter's alpha Decisions API.
3 *
4 * @stability unstable
5 * @since 4.0.0
6 */
7import * as AiError from "effect/ai/AiError"
8import * as DecisionModel from "effect/ai/DecisionModel"
9import * as Model from "effect/ai/Model"
10import * as Context from "effect/Context"
11import * as Effect from "effect/Effect"
12import * as Layer from "effect/Layer"
13import { OpenRouterClient } from "./OpenRouterClient.ts"
14import type * as OpenRouterSchema from "./OpenRouterSchema.ts"
15
16/**
17 * Request options that override model defaults at call time.
18 *
19 * @stability unstable
20 * @category services
21 * @since 4.0.0
22 */
23export class Config extends Context.Service<
24 Config,
25 Pick<
26 typeof OpenRouterSchema.DecisionsRequest.Encoded,
27 "provider" | "session_id" | "user" | "trace"
28 >
29>()("@effect/ai-openrouter/OpenRouterDecisionModel/Config") {}
30
31/**
32 * Creates an OpenRouter model descriptor with decision support.
33 *
34 * @stability unstable
35 * @category constructors
36 * @since 4.0.0
37 */
38export const model = (
39 model: string,
40 config?: typeof Config.Service
41): Model.Model<"openrouter", DecisionModel.DecisionModel, OpenRouterClient> =>
42 Model.make("openrouter", model, layer({ model, config }))
43
44/**
45 * Creates a decision service that requires full choice and score distributions.
46 * Score indices map to criteria labels; cost, id, and provider metadata are omitted.
47 * Probabilities arrive rounded to two decimals, so small sum drift is rescaled.
48 *
49 * @stability unstable
50 * @category constructors
51 * @since 4.0.0
52 */
53export const make = Effect.fnUntraced(function*(options: {
54 readonly model: string
55 readonly config?: typeof Config.Service | undefined
56}): Effect.fn.Return<DecisionModel.DecisionModel, never, OpenRouterClient> {
57 const client = yield* OpenRouterClient
58 const config = Effect.contextWith((services: Context.Context<never>) =>
59 Effect.succeed({ ...options.config, ...Context.getOrUndefined(services, Config) })
60 )
61 return yield* DecisionModel.make({
62 probabilityPrecision: 2,
63 decide: Effect.fnUntraced(function*({ state, decisions }) {
64 if (state === null || typeof state === "number" || typeof state === "boolean") {
65 return yield* AiError.make({
66 module: "OpenRouterDecisionModel",
67 method: "decide",
68 reason: new AiError.InvalidUserInputError({
69 description: "OpenRouter decision state must be a string, object, or array"
70 })
71 })
72 }
73 const questions: Record<string, typeof OpenRouterSchema.DecisionsQuestion.Encoded> = Object.create(null)
74 for (const [key, decision] of Object.entries(decisions)) {
75 switch (decision._tag) {
76 case "Classify":
77 questions[key] = { type: "choice", instructions: decision.instructions, criteria: decision.criteria }
78 break
79 case "Rate":
80 questions[key] = { type: "score", instructions: decision.instructions, criteria: decision.criteria }
81 break
82 case "Probability":
83 questions[key] = { type: "noul", instructions: decision.instructions, criteria: decision.criteria }
84 break
85 }
86 }
87 const [response] = yield* client.createDecisions({ ...yield* config, model: options.model, state, questions })
88 const answers: Record<string, DecisionModel.ProviderAnswer> = Object.create(null)
89 for (const [key, answer] of Object.entries(response.answers)) {
90 if (answer.type === "noul") {
91 answers[key] = { _tag: "Probability", probability: answer.noul }
92 continue
93 }
94 if (answer.probabilities === undefined) {
95 return yield* AiError.make({
96 module: "OpenRouterDecisionModel",
97 method: "decide",
98 reason: new AiError.InvalidOutputError({
99 description: `Provider returned no probabilities for decision "${key}"`
100 })
101 })
102 }
103 if (answer.type === "choice") {
104 answers[key] = {
105 _tag: "Classify",
106 label: answer.choice,
107 probabilities: answer.probabilities,
108 confidence: answer.confidence
109 }
110 } else {
111 const decision = decisions[key]
112 const probabilities: Record<string, number> = Object.create(null)
113 if (decision?._tag === "Rate") {
114 for (const [index, label] of decision.criteria.entries()) {
115 const probability = answer.probabilities[String(index)]
116 if (probability !== undefined) {
117 probabilities[label] = probability
118 }
119 }
120 }
121 answers[key] = { _tag: "Rate", rating: answer.score, probabilities, confidence: answer.confidence }
122 }
123 }
124 return {
125 answers,
126 usage: { inputTokens: response.usage.input_tokens, outputTokens: response.usage.output_tokens }
127 }
128 })
129 })
130})
131
132/**
133 * Provides a decision model using the OpenRouter client service.
134 *
135 * @stability unstable
136 * @category layers
137 * @since 4.0.0
138 */
139export const layer = (options: {
140 readonly model: string
141 readonly config?: typeof Config.Service | undefined
142}): Layer.Layer<DecisionModel.DecisionModel, never, OpenRouterClient> =>
143 Layer.effect(DecisionModel.DecisionModel, make(options))