@gobing-ai/ts-ai-decision 0.5.29 → 0.5.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The TypeSafe backend driver — the only decision file that imports
3
+ * `@typesafe-ai/sdk` (0069 pin). Client wiring (R1/R2), neutral⇄SDK question
4
+ * and answer mapping (R4/R5), and the error translation table (R7) live here;
5
+ * everything above this file stays vendor-neutral.
6
+ */
7
+
8
+ import {
9
+ type Answer,
10
+ DecisionAuthError,
11
+ DecisionBackendError,
12
+ DecisionConnectionError,
13
+ type DecisionDriver,
14
+ DecisionRateLimitError,
15
+ DecisionRequestError,
16
+ DecisionTimeoutError,
17
+ type Desc,
18
+ type Question,
19
+ validateAnswers,
20
+ validateQuestions,
21
+ } from '@gobing-ai/ts-ai-runner';
22
+ import type {
23
+ NoulQuestion,
24
+ ScoreResponse,
25
+ Question as SdkQuestion,
26
+ ResultFor as SdkResultFor,
27
+ SystemOneResult,
28
+ } from '@typesafe-ai/sdk';
29
+ import {
30
+ APIConnectionError,
31
+ APIError,
32
+ APITimeoutError,
33
+ AuthenticationError,
34
+ choice,
35
+ PermissionDeniedError,
36
+ RateLimitError,
37
+ score,
38
+ TypeSafeClient,
39
+ TypeSafeError,
40
+ } from '@typesafe-ai/sdk';
41
+
42
+ /** Configuration the facade resolves (key per R7) and forwards to the TypeSafe driver. */
43
+ export interface TypesafeDriverConfig {
44
+ apiKey: string;
45
+ model?: string;
46
+ baseURL?: string;
47
+ timeoutMs?: number;
48
+ maxRetries?: number;
49
+ fetch?: typeof fetch;
50
+ }
51
+
52
+ /**
53
+ * Build the TypeSafe driver: exactly one `TypeSafeClient` per driver instance
54
+ * (R2), the API key always passed explicitly so the SDK's `TYPESAFE_API_KEY`
55
+ * self-resolution never runs (R1). All transport failures — at construction
56
+ * or at ask time — come back as `DecisionError`s, never SDK classes (R7).
57
+ */
58
+ export function createTypesafeDriver(config: TypesafeDriverConfig): DecisionDriver {
59
+ let client: TypeSafeClient;
60
+ try {
61
+ client = new TypeSafeClient({
62
+ apiKey: config.apiKey,
63
+ // baseURL is forwarded as-is. SDK 0.7.0 still exports no default
64
+ // base-URL constant (`ENV.baseURL` is the env-var NAME, and the
65
+ // documented default `https://api.typesafe.ai` lives only in the
66
+ // config JSDoc), so an omitted baseURL keeps letting the SDK
67
+ // self-resolve `TYPESAFE_BASE_URL` — documented residual for 0073.
68
+ baseURL: config.baseURL,
69
+ defaultModel: config.model,
70
+ timeout: config.timeoutMs,
71
+ retry: config.maxRetries === undefined ? undefined : { maxRetries: config.maxRetries },
72
+ fetch: config.fetch,
73
+ });
74
+ } catch (err) {
75
+ translateError(err);
76
+ }
77
+
78
+ return {
79
+ name: 'typesafe',
80
+ async ask({ state, questions, model }) {
81
+ validateQuestions(questions);
82
+
83
+ // The neutral contract allows `null` state ("no caller context") and the SDK type
84
+ // accepts it, but the wire drops a null field and the API answers 422 "state: Field
85
+ // required" — so every fully parameterless decision used to fall back on this backend.
86
+ // Same null→'' mapping the laya and fm drivers already apply; structured state passes
87
+ // through untouched (the wire accepts an object or array).
88
+ let result: SystemOneResult<Record<string, SdkQuestion>>;
89
+ try {
90
+ const sdkQuestions = Object.fromEntries(
91
+ Object.entries(questions).map(([name, question]) => [name, toSdkQuestion(question)]),
92
+ );
93
+ result = await client.systemOne({ state: state ?? '', questions: sdkQuestions, model });
94
+ } catch (err) {
95
+ translateError(err);
96
+ }
97
+
98
+ // R6: the SDK echoes the request's question names, so the mapped
99
+ // record keeps the caller's keys in correspondence. R8: `model`
100
+ // and `usage` on the result are deliberately not surfaced.
101
+ if (!result?.answers || typeof result.answers !== 'object' || Array.isArray(result.answers)) {
102
+ throw new DecisionBackendError('Invalid decision response: expected answers map', undefined);
103
+ }
104
+ const answers = Object.fromEntries(
105
+ Object.entries(result.answers).map(([name, answer]) => [name, fromSdkAnswer(answer)]),
106
+ );
107
+ validateAnswers(questions, answers);
108
+ return answers;
109
+ },
110
+ };
111
+ }
112
+
113
+ /** Neutral question → SDK wire question (R4). `null` is "undescribed" on the wire. */
114
+ function toSdkQuestion(question: Question): SdkQuestion {
115
+ switch (question.kind) {
116
+ case 'choice':
117
+ return choice(question.prompt ?? null, question.labels);
118
+ case 'score':
119
+ return score(question.prompt ?? null, question.rubric);
120
+ case 'noul': {
121
+ const outcomes =
122
+ question.yes === undefined && question.no === undefined
123
+ ? undefined
124
+ : { true: question.yes, false: question.no };
125
+ // SDK 0.7.0's `noul()` overloads cannot express an undescribed noul
126
+ // (`instructions` null with no criteria), which the neutral contract allows,
127
+ // so the same object is built directly. The intent type is identical to the
128
+ // builder's, and the wire shape is unchanged for every described case.
129
+ const noulQuestion: NoulQuestion = { type: 'noul', instructions: question.prompt ?? null };
130
+ if (outcomes !== undefined) noulQuestion.criteria = outcomes;
131
+ return noulQuestion;
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Read the rubric legend as the neutral index → description map. The SDK's `ScoreLegend`
138
+ * threads a recursive conditional (`SentContent`) that the compiler cannot resolve once the
139
+ * question map is the neutral union, so the value is handed over here; `validateAnswers`
140
+ * enforces the real shape at runtime.
141
+ */
142
+ function legendOf(wire: ScoreResponse): Record<number, Desc> {
143
+ return wire.legend as unknown as Record<number, Desc>;
144
+ }
145
+
146
+ /** SDK wire response → neutral answer (R5). Noul gets no confidence — the wire has none and none is invented. */
147
+ function fromSdkAnswer(answer: SdkResultFor<SdkQuestion>): Answer {
148
+ if (!answer || typeof answer !== 'object')
149
+ throw new DecisionBackendError('Invalid decision response: expected answer object', undefined);
150
+ switch (answer.type) {
151
+ case 'choice':
152
+ return {
153
+ kind: 'choice',
154
+ label: answer.choice,
155
+ confidence: answer.confidence,
156
+ probabilities: answer.probabilities,
157
+ };
158
+ case 'score': {
159
+ // SDK 0.7.0 infers `ScoreResponse<ScoreCriteria>` from `ResultFor<Question>` too deeply to
160
+ // instantiate (TS2589), so the branch reads through the non-generic default instead and
161
+ // hands the legend over through a helper. `validateAnswers` checks the values at runtime.
162
+ const wire: ScoreResponse = answer;
163
+ return {
164
+ kind: 'score',
165
+ score: wire.score,
166
+ confidence: wire.confidence,
167
+ legend: legendOf(wire),
168
+ probabilities: wire.probabilities,
169
+ };
170
+ }
171
+ case 'noul':
172
+ return { kind: 'noul', probability: answer.noul };
173
+ default:
174
+ throw new DecisionBackendError('Invalid decision response: unknown answer kind', undefined);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * R7, per the design doc's error table. Ordered subclass-before-base:
180
+ * `APITimeoutError` extends `APIConnectionError`, and the HTTP subclasses
181
+ * extend `APIError`. Rows the table does not name fall to the nearest home:
182
+ * 4xx (`NotFoundError` included) is a `DecisionRequestError`, and any local
183
+ * `TypeSafeError` (empty questions, invalid client config) lands there too
184
+ * with `status: undefined` — so no SDK class ever escapes. Foreign errors are
185
+ * rethrown untouched; they are not this package's to translate.
186
+ */
187
+ function translateError(err: unknown): never {
188
+ const message = err instanceof Error ? err.message : String(err);
189
+ if (err instanceof RateLimitError) {
190
+ throw new DecisionRateLimitError(message, err.status, err.retryAfterMs, { cause: err });
191
+ }
192
+ if (err instanceof AuthenticationError || err instanceof PermissionDeniedError) {
193
+ throw new DecisionAuthError(message, err.status, { cause: err });
194
+ }
195
+ if (err instanceof APITimeoutError) {
196
+ throw new DecisionTimeoutError(message, err.timeoutMs, { cause: err });
197
+ }
198
+ if (err instanceof APIConnectionError) {
199
+ throw new DecisionConnectionError(message, { cause: err });
200
+ }
201
+ if (err instanceof APIError) {
202
+ if (err.status < 500) {
203
+ const raw = typeof err.body === 'string' ? err.body : (JSON.stringify(err.body) ?? '');
204
+ throw new DecisionRequestError(message, err.status, raw.slice(0, 200) || undefined, { cause: err });
205
+ }
206
+ throw new DecisionBackendError(message, err.status, { cause: err });
207
+ }
208
+ if (err instanceof TypeSafeError) {
209
+ throw new DecisionRequestError(message, undefined, message, { cause: err });
210
+ }
211
+ throw err;
212
+ }