@struktur/sdk 1.2.1 → 2.1.0
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.
- package/LICENSE +110 -0
- package/README.md +7 -3
- package/package.json +3 -1
- package/src/agent-cli-integration.test.ts +47 -0
- package/src/agent-export.test.ts +17 -0
- package/src/agent-tool-labels.test.ts +50 -0
- package/src/artifacts/AGENTS.md +1 -1
- package/src/auth/config.ts +57 -0
- package/src/extract.ts +55 -19
- package/src/index.ts +17 -0
- package/src/llm/LLMClient.test.ts +198 -0
- package/src/llm/LLMClient.ts +178 -20
- package/src/llm/RetryingRunner.ts +83 -1
- package/src/llm/resolveModel.ts +86 -0
- package/src/strategies/DoublePassAutoMergeStrategy.ts +140 -0
- package/src/strategies/DoublePassStrategy.ts +87 -0
- package/src/strategies/ParallelAutoMergeStrategy.ts +104 -0
- package/src/strategies/ParallelStrategy.ts +51 -0
- package/src/strategies/SequentialAutoMergeStrategy.ts +103 -0
- package/src/strategies/SequentialStrategy.ts +23 -0
- package/src/strategies/SimpleStrategy.ts +20 -0
- package/src/strategies/utils.ts +42 -3
- package/src/types.ts +67 -9
- package/src/validation/AGENTS.md +3 -2
- package/src/validation/validator.test.ts +32 -0
- package/src/validation/validator.ts +8 -0
|
@@ -194,3 +194,201 @@ test("generateStructured uses custom schema description", async () => {
|
|
|
194
194
|
|
|
195
195
|
expect(calls[0]?.output).toHaveProperty("description", "Extract data");
|
|
196
196
|
});
|
|
197
|
+
|
|
198
|
+
test("generateStructured shows friendly error when model doesn't support images", async () => {
|
|
199
|
+
calls.length = 0;
|
|
200
|
+
generateTextImpl = async () => {
|
|
201
|
+
throw {
|
|
202
|
+
responseBody: '{"error":{"message":"No endpoints found that support image input","code":404}}',
|
|
203
|
+
statusCode: 404,
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
expect(
|
|
208
|
+
async () =>
|
|
209
|
+
await generateStructured({
|
|
210
|
+
model: { modelId: "meta-llama/llama-3.1-8b-instruct" },
|
|
211
|
+
schema: { type: "object" },
|
|
212
|
+
system: "sys",
|
|
213
|
+
user: [{ type: "text", text: "prompt" }, { type: "image", image: "base64data" }],
|
|
214
|
+
}),
|
|
215
|
+
).toThrow(
|
|
216
|
+
'Model "meta-llama/llama-3.1-8b-instruct" does not support image input. Please use a model that supports images (e.g., gpt-4o, claude-3-5-sonnet, gemini-1.5-pro) or remove the --images and --screenshots flags.',
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("generateStructured rethrows other API errors", async () => {
|
|
221
|
+
calls.length = 0;
|
|
222
|
+
const originalError = new Error("Some other error");
|
|
223
|
+
generateTextImpl = async () => {
|
|
224
|
+
throw originalError;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
expect(
|
|
228
|
+
async () =>
|
|
229
|
+
await generateStructured({
|
|
230
|
+
model: {},
|
|
231
|
+
schema: { type: "object" },
|
|
232
|
+
system: "sys",
|
|
233
|
+
user: "prompt",
|
|
234
|
+
}),
|
|
235
|
+
).toThrow("Some other error");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("generateStructured shows friendly error for internal server error", async () => {
|
|
239
|
+
calls.length = 0;
|
|
240
|
+
generateTextImpl = async () => {
|
|
241
|
+
throw {
|
|
242
|
+
statusCode: 200,
|
|
243
|
+
responseBody: undefined,
|
|
244
|
+
data: {
|
|
245
|
+
code: 500,
|
|
246
|
+
message: "Internal Server Error",
|
|
247
|
+
type: null,
|
|
248
|
+
param: null,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
expect(
|
|
254
|
+
async () =>
|
|
255
|
+
await generateStructured({
|
|
256
|
+
model: { modelId: "openai/gpt-5-mini" },
|
|
257
|
+
schema: { type: "object" },
|
|
258
|
+
system: "sys",
|
|
259
|
+
user: "prompt",
|
|
260
|
+
}),
|
|
261
|
+
).toThrow(
|
|
262
|
+
'Provider error for model "openai/gpt-5-mini": Internal server error. The model or provider may be experiencing issues. Please try again or use a different model.',
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("generateStructured shows friendly error for authentication failure", async () => {
|
|
267
|
+
calls.length = 0;
|
|
268
|
+
generateTextImpl = async () => {
|
|
269
|
+
throw {
|
|
270
|
+
statusCode: 401,
|
|
271
|
+
responseBody: '{"error":{"message":"Invalid API key"}}',
|
|
272
|
+
data: {
|
|
273
|
+
code: 401,
|
|
274
|
+
message: "Invalid API key",
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
expect(
|
|
280
|
+
async () =>
|
|
281
|
+
await generateStructured({
|
|
282
|
+
model: { modelId: "gpt-4o" },
|
|
283
|
+
schema: { type: "object" },
|
|
284
|
+
system: "sys",
|
|
285
|
+
user: "prompt",
|
|
286
|
+
}),
|
|
287
|
+
).toThrow(
|
|
288
|
+
'Authentication failed for model "gpt-4o". Please check your API key is valid and has the necessary permissions.',
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("generateStructured shows friendly error for rate limit", async () => {
|
|
293
|
+
calls.length = 0;
|
|
294
|
+
generateTextImpl = async () => {
|
|
295
|
+
throw {
|
|
296
|
+
statusCode: 429,
|
|
297
|
+
responseBody: '{"error":{"message":"Rate limit exceeded"}}',
|
|
298
|
+
data: {
|
|
299
|
+
code: 429,
|
|
300
|
+
message: "Rate limit exceeded",
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
expect(
|
|
306
|
+
async () =>
|
|
307
|
+
await generateStructured({
|
|
308
|
+
model: { modelId: "claude-3-5-sonnet" },
|
|
309
|
+
schema: { type: "object" },
|
|
310
|
+
system: "sys",
|
|
311
|
+
user: "prompt",
|
|
312
|
+
}),
|
|
313
|
+
).toThrow(
|
|
314
|
+
'Rate limit exceeded for model "claude-3-5-sonnet". Please wait a moment and try again, or use a different model.',
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("generateStructured shows friendly error for model not found", async () => {
|
|
319
|
+
calls.length = 0;
|
|
320
|
+
generateTextImpl = async () => {
|
|
321
|
+
throw {
|
|
322
|
+
statusCode: 404,
|
|
323
|
+
responseBody: '{"error":{"message":"Model not found"}}',
|
|
324
|
+
data: {
|
|
325
|
+
code: 404,
|
|
326
|
+
message: "Model not found",
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
expect(
|
|
332
|
+
async () =>
|
|
333
|
+
await generateStructured({
|
|
334
|
+
model: { modelId: "nonexistent-model" },
|
|
335
|
+
schema: { type: "object" },
|
|
336
|
+
system: "sys",
|
|
337
|
+
user: "prompt",
|
|
338
|
+
}),
|
|
339
|
+
).toThrow(
|
|
340
|
+
'Model "nonexistent-model" not found or unavailable. Model not found Please check the model name or try a different model.',
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("generateStructured shows friendly error for access denied", async () => {
|
|
345
|
+
calls.length = 0;
|
|
346
|
+
generateTextImpl = async () => {
|
|
347
|
+
throw {
|
|
348
|
+
statusCode: 403,
|
|
349
|
+
responseBody: '{"error":{"message":"Access denied"}}',
|
|
350
|
+
data: {
|
|
351
|
+
code: 403,
|
|
352
|
+
message: "Access denied",
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
expect(
|
|
358
|
+
async () =>
|
|
359
|
+
await generateStructured({
|
|
360
|
+
model: { modelId: "gpt-4-turbo" },
|
|
361
|
+
schema: { type: "object" },
|
|
362
|
+
system: "sys",
|
|
363
|
+
user: "prompt",
|
|
364
|
+
}),
|
|
365
|
+
).toThrow(
|
|
366
|
+
'Access denied for model "gpt-4-turbo". Your API key may not have access to this model. Please check your subscription or try a different model.',
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("generateStructured shows generic provider error message", async () => {
|
|
371
|
+
calls.length = 0;
|
|
372
|
+
generateTextImpl = async () => {
|
|
373
|
+
throw {
|
|
374
|
+
statusCode: 400,
|
|
375
|
+
responseBody: '{"error":{"message":"Context length exceeded"}}',
|
|
376
|
+
data: {
|
|
377
|
+
code: 400,
|
|
378
|
+
message: "Context length exceeded",
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
expect(
|
|
384
|
+
async () =>
|
|
385
|
+
await generateStructured({
|
|
386
|
+
model: { modelId: "gpt-3.5-turbo" },
|
|
387
|
+
schema: { type: "object" },
|
|
388
|
+
system: "sys",
|
|
389
|
+
user: "prompt",
|
|
390
|
+
}),
|
|
391
|
+
).toThrow(
|
|
392
|
+
'Provider error for model "gpt-3.5-turbo": Context length exceeded',
|
|
393
|
+
);
|
|
394
|
+
});
|
package/src/llm/LLMClient.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateText, Output, jsonSchema, type ModelMessage } from "ai";
|
|
2
|
-
import type { AnyJSONSchema, Usage } from "../types";
|
|
2
|
+
import type { AnyJSONSchema, Usage, TelemetryAdapter } from "../types";
|
|
3
3
|
import type { UserContent } from "./message";
|
|
4
4
|
|
|
5
5
|
type GenerateTextParams = Parameters<typeof generateText>[0];
|
|
@@ -15,6 +15,14 @@ export type StructuredRequest<T> = {
|
|
|
15
15
|
schemaName?: string;
|
|
16
16
|
schemaDescription?: string;
|
|
17
17
|
strict?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Telemetry adapter for tracing LLM calls
|
|
20
|
+
*/
|
|
21
|
+
telemetry?: TelemetryAdapter;
|
|
22
|
+
/**
|
|
23
|
+
* Parent span for creating hierarchical traces
|
|
24
|
+
*/
|
|
25
|
+
parentSpan?: { id: string; traceId: string; name: string; kind: string; startTime: number; parentId?: string };
|
|
18
26
|
};
|
|
19
27
|
|
|
20
28
|
export type StructuredResponse<T> = {
|
|
@@ -36,6 +44,21 @@ const isZodSchema = (
|
|
|
36
44
|
export const generateStructured = async <T>(
|
|
37
45
|
request: StructuredRequest<T>,
|
|
38
46
|
): Promise<StructuredResponse<T>> => {
|
|
47
|
+
const { telemetry, parentSpan } = request;
|
|
48
|
+
|
|
49
|
+
// Start LLM span if telemetry is enabled
|
|
50
|
+
const llmSpan = telemetry?.startSpan({
|
|
51
|
+
name: "llm.generateStructured",
|
|
52
|
+
kind: "LLM",
|
|
53
|
+
parentSpan,
|
|
54
|
+
attributes: {
|
|
55
|
+
"llm.schema_name": request.schemaName ?? "extract",
|
|
56
|
+
"llm.strict": request.strict ?? false,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const startTime = Date.now();
|
|
61
|
+
|
|
39
62
|
const schema = isZodSchema(request.schema)
|
|
40
63
|
? request.schema
|
|
41
64
|
: jsonSchema(request.schema as AnyJSONSchema);
|
|
@@ -61,26 +84,128 @@ export const generateStructured = async <T>(
|
|
|
61
84
|
}
|
|
62
85
|
: undefined;
|
|
63
86
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
let result;
|
|
88
|
+
try {
|
|
89
|
+
result = await generateText({
|
|
90
|
+
model: request.model as ModelType,
|
|
91
|
+
output: Output.object({
|
|
92
|
+
schema: schema as GenerateTextParams extends { schema: infer S }
|
|
93
|
+
? S
|
|
94
|
+
: never,
|
|
95
|
+
name: request.schemaName ?? "extract",
|
|
96
|
+
description: request.schemaDescription,
|
|
97
|
+
}),
|
|
98
|
+
providerOptions: {
|
|
99
|
+
openai: {
|
|
100
|
+
strictJsonSchema: request.strict ?? false,
|
|
101
|
+
},
|
|
76
102
|
},
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
})
|
|
103
|
+
system: request.system,
|
|
104
|
+
messages: (request.messages ?? [
|
|
105
|
+
{ role: "user", content: request.user },
|
|
106
|
+
]) as MessageType,
|
|
107
|
+
...(providerOptions ? { providerOptions } : {}),
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Determine model ID for error messages
|
|
111
|
+
const modelId =
|
|
112
|
+
typeof request.model === "object" && request.model !== null
|
|
113
|
+
? (request.model as { modelId?: string }).modelId ??
|
|
114
|
+
JSON.stringify(request.model)
|
|
115
|
+
: String(request.model);
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
error &&
|
|
119
|
+
typeof error === "object" &&
|
|
120
|
+
"responseBody" in error &&
|
|
121
|
+
"statusCode" in error
|
|
122
|
+
) {
|
|
123
|
+
const apiError = error as {
|
|
124
|
+
responseBody: unknown;
|
|
125
|
+
statusCode: number;
|
|
126
|
+
data?: {
|
|
127
|
+
code?: number;
|
|
128
|
+
message?: string;
|
|
129
|
+
type?: string | null;
|
|
130
|
+
param?: string | null;
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const responseBody = apiError.responseBody;
|
|
135
|
+
const errorData = apiError.data;
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
typeof responseBody === "string" &&
|
|
139
|
+
responseBody.includes("No endpoints found that support image input")
|
|
140
|
+
) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Model "${modelId}" does not support image input. Please use a model that supports images (e.g., gpt-4o, claude-3-5-sonnet, gemini-1.5-pro) or remove the --images and --screenshots flags.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (errorData?.code === 500 || errorData?.message?.includes("Internal Server Error")) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Provider error for model "${modelId}": Internal server error. The model or provider may be experiencing issues. Please try again or use a different model.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (apiError.statusCode === 401 || errorData?.code === 401) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Authentication failed for model "${modelId}". Please check your API key is valid and has the necessary permissions.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (apiError.statusCode === 403 || errorData?.code === 403) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Access denied for model "${modelId}". Your API key may not have access to this model. Please check your subscription or try a different model.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (apiError.statusCode === 429 || errorData?.code === 429) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Rate limit exceeded for model "${modelId}". Please wait a moment and try again, or use a different model.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (apiError.statusCode === 404 || errorData?.code === 404) {
|
|
171
|
+
const errorMsg = errorData?.message || "Model not found";
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Model "${modelId}" not found or unavailable. ${errorMsg} Please check the model name or try a different model.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (errorData?.message) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Provider error for model "${modelId}": ${errorData.message}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Record error in telemetry
|
|
185
|
+
if (llmSpan && telemetry) {
|
|
186
|
+
const latencyMs = Date.now() - startTime;
|
|
187
|
+
telemetry.recordEvent(llmSpan, {
|
|
188
|
+
type: "llm_call",
|
|
189
|
+
model: modelId,
|
|
190
|
+
provider: "unknown", // Will be determined by the model
|
|
191
|
+
input: {
|
|
192
|
+
messages: request.messages ?? [{ role: "user", content: typeof request.user === "string" ? request.user : "" }],
|
|
193
|
+
temperature: undefined,
|
|
194
|
+
maxTokens: undefined,
|
|
195
|
+
schema: request.schema,
|
|
196
|
+
},
|
|
197
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
198
|
+
latencyMs,
|
|
199
|
+
});
|
|
200
|
+
telemetry.endSpan(llmSpan, {
|
|
201
|
+
status: "error",
|
|
202
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
203
|
+
latencyMs,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
84
209
|
|
|
85
210
|
const usageRaw = result.usage ?? {};
|
|
86
211
|
const inputTokens =
|
|
@@ -102,5 +227,38 @@ export const generateStructured = async <T>(
|
|
|
102
227
|
totalTokens,
|
|
103
228
|
};
|
|
104
229
|
|
|
230
|
+
// Record successful LLM call in telemetry
|
|
231
|
+
if (llmSpan && telemetry) {
|
|
232
|
+
const latencyMs = Date.now() - startTime;
|
|
233
|
+
telemetry.recordEvent(llmSpan, {
|
|
234
|
+
type: "llm_call",
|
|
235
|
+
model: typeof request.model === "object" && request.model !== null
|
|
236
|
+
? (request.model as { modelId?: string }).modelId ?? "unknown"
|
|
237
|
+
: String(request.model),
|
|
238
|
+
provider: preferredProvider ?? "unknown",
|
|
239
|
+
input: {
|
|
240
|
+
messages: request.messages ?? [{ role: "user", content: typeof request.user === "string" ? request.user : "" }],
|
|
241
|
+
temperature: undefined,
|
|
242
|
+
maxTokens: undefined,
|
|
243
|
+
schema: request.schema,
|
|
244
|
+
},
|
|
245
|
+
output: {
|
|
246
|
+
content: JSON.stringify(result.output),
|
|
247
|
+
structured: true,
|
|
248
|
+
usage: {
|
|
249
|
+
input: inputTokens,
|
|
250
|
+
output: outputTokens,
|
|
251
|
+
total: totalTokens,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
latencyMs,
|
|
255
|
+
});
|
|
256
|
+
telemetry.endSpan(llmSpan, {
|
|
257
|
+
status: "ok",
|
|
258
|
+
output: result.output,
|
|
259
|
+
latencyMs,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
105
263
|
return { data: result.output as T, usage };
|
|
106
264
|
};
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
validateAllowingMissingRequired,
|
|
6
6
|
} from "../validation/validator";
|
|
7
7
|
import type { ModelMessage } from "ai";
|
|
8
|
-
import type { ExtractionEvents, Usage } from "../types";
|
|
8
|
+
import type { ExtractionEvents, Usage, TelemetryAdapter } from "../types";
|
|
9
9
|
import type { DebugLogger } from "../debug/logger";
|
|
10
10
|
import { generateStructured } from "./LLMClient";
|
|
11
11
|
import type { UserContent } from "./message";
|
|
@@ -22,9 +22,30 @@ export type RetryOptions<T> = {
|
|
|
22
22
|
strict?: boolean;
|
|
23
23
|
debug?: DebugLogger;
|
|
24
24
|
callId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Telemetry adapter for tracing validation and retries
|
|
27
|
+
*/
|
|
28
|
+
telemetry?: TelemetryAdapter;
|
|
29
|
+
/**
|
|
30
|
+
* Parent span for creating hierarchical traces
|
|
31
|
+
*/
|
|
32
|
+
parentSpan?: { id: string; traceId: string; name: string; kind: string; startTime: number; parentId?: string };
|
|
25
33
|
};
|
|
26
34
|
|
|
27
35
|
export const runWithRetries = async <T>(options: RetryOptions<T>) => {
|
|
36
|
+
const { telemetry, parentSpan } = options;
|
|
37
|
+
|
|
38
|
+
// Start validation/retry span if telemetry is enabled
|
|
39
|
+
const retrySpan = telemetry?.startSpan({
|
|
40
|
+
name: "struktur.validation_retry",
|
|
41
|
+
kind: "CHAIN",
|
|
42
|
+
parentSpan,
|
|
43
|
+
attributes: {
|
|
44
|
+
"retry.max_attempts": options.maxAttempts ?? 3,
|
|
45
|
+
"retry.schema_name": options.schemaName ?? "extract",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
28
49
|
const ajv = createAjv();
|
|
29
50
|
const maxAttempts = options.maxAttempts ?? 3;
|
|
30
51
|
const messages: ModelMessage[] = [{ role: "user", content: options.user }];
|
|
@@ -76,6 +97,8 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
|
|
|
76
97
|
user: options.user,
|
|
77
98
|
messages,
|
|
78
99
|
strict: options.strict,
|
|
100
|
+
telemetry,
|
|
101
|
+
parentSpan: retrySpan,
|
|
79
102
|
});
|
|
80
103
|
const durationMs = Date.now() - startTime;
|
|
81
104
|
|
|
@@ -105,6 +128,24 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
|
|
|
105
128
|
durationMs,
|
|
106
129
|
});
|
|
107
130
|
|
|
131
|
+
// Record successful validation
|
|
132
|
+
if (retrySpan && telemetry) {
|
|
133
|
+
telemetry.recordEvent(retrySpan, {
|
|
134
|
+
type: "validation",
|
|
135
|
+
attempt,
|
|
136
|
+
maxAttempts,
|
|
137
|
+
schema: options.schema,
|
|
138
|
+
input: result.data,
|
|
139
|
+
success: true,
|
|
140
|
+
latencyMs: durationMs,
|
|
141
|
+
});
|
|
142
|
+
telemetry.endSpan(retrySpan, {
|
|
143
|
+
status: "ok",
|
|
144
|
+
output: validated,
|
|
145
|
+
latencyMs: durationMs,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
108
149
|
return { data: validated, usage };
|
|
109
150
|
} else {
|
|
110
151
|
const validationResult = validateAllowingMissingRequired<T>(
|
|
@@ -125,6 +166,24 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
|
|
|
125
166
|
durationMs,
|
|
126
167
|
});
|
|
127
168
|
|
|
169
|
+
// Record successful validation
|
|
170
|
+
if (retrySpan && telemetry) {
|
|
171
|
+
telemetry.recordEvent(retrySpan, {
|
|
172
|
+
type: "validation",
|
|
173
|
+
attempt,
|
|
174
|
+
maxAttempts,
|
|
175
|
+
schema: options.schema,
|
|
176
|
+
input: result.data,
|
|
177
|
+
success: true,
|
|
178
|
+
latencyMs: durationMs,
|
|
179
|
+
});
|
|
180
|
+
telemetry.endSpan(retrySpan, {
|
|
181
|
+
status: "ok",
|
|
182
|
+
output: validationResult.data,
|
|
183
|
+
latencyMs: durationMs,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
128
187
|
return { data: validationResult.data, usage };
|
|
129
188
|
}
|
|
130
189
|
|
|
@@ -143,6 +202,20 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
|
|
|
143
202
|
errors: error.errors,
|
|
144
203
|
});
|
|
145
204
|
|
|
205
|
+
// Record failed validation
|
|
206
|
+
if (retrySpan && telemetry) {
|
|
207
|
+
telemetry.recordEvent(retrySpan, {
|
|
208
|
+
type: "validation",
|
|
209
|
+
attempt,
|
|
210
|
+
maxAttempts,
|
|
211
|
+
schema: options.schema,
|
|
212
|
+
input: result.data,
|
|
213
|
+
success: false,
|
|
214
|
+
errors: error.errors,
|
|
215
|
+
latencyMs: durationMs,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
146
219
|
// Emit retry event before attempting retry
|
|
147
220
|
const nextAttempt = attempt + 1;
|
|
148
221
|
if (nextAttempt <= maxAttempts) {
|
|
@@ -180,6 +253,15 @@ export const runWithRetries = async <T>(options: RetryOptions<T>) => {
|
|
|
180
253
|
error: (error as Error).message,
|
|
181
254
|
});
|
|
182
255
|
|
|
256
|
+
// Record error in telemetry
|
|
257
|
+
if (retrySpan && telemetry) {
|
|
258
|
+
telemetry.endSpan(retrySpan, {
|
|
259
|
+
status: "error",
|
|
260
|
+
error: error as Error,
|
|
261
|
+
latencyMs: durationMs,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
183
265
|
break;
|
|
184
266
|
}
|
|
185
267
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { resolveProviderEnvVar, resolveProviderToken } from "../auth/tokens";
|
|
2
|
+
|
|
3
|
+
export const resolveModel = async (model: string) => {
|
|
4
|
+
(globalThis as { AI_SDK_LOG_WARNINGS?: boolean }).AI_SDK_LOG_WARNINGS ??= false;
|
|
5
|
+
process.env.AI_SDK_LOG_WARNINGS ??= "false";
|
|
6
|
+
const [provider, ...rest] = model.split("/");
|
|
7
|
+
const modelName = rest.join("/");
|
|
8
|
+
|
|
9
|
+
if (!provider || !modelName) {
|
|
10
|
+
throw new Error(`Invalid model format: ${model}. Expected format: provider/model (e.g., openai/gpt-4)`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const envVar = resolveProviderEnvVar(provider);
|
|
14
|
+
if (envVar && !process.env[envVar]) {
|
|
15
|
+
const storedToken = await resolveProviderToken(provider);
|
|
16
|
+
if (storedToken) {
|
|
17
|
+
process.env[envVar] = storedToken;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
switch (provider) {
|
|
22
|
+
case "openai": {
|
|
23
|
+
const { openai } = await import("@ai-sdk/openai");
|
|
24
|
+
return openai(modelName);
|
|
25
|
+
}
|
|
26
|
+
case "anthropic": {
|
|
27
|
+
const { anthropic } = await import("@ai-sdk/anthropic");
|
|
28
|
+
return anthropic(modelName);
|
|
29
|
+
}
|
|
30
|
+
case "google": {
|
|
31
|
+
const { google } = await import("@ai-sdk/google");
|
|
32
|
+
return google(modelName);
|
|
33
|
+
}
|
|
34
|
+
case "opencode": {
|
|
35
|
+
const envVar = resolveProviderEnvVar("opencode");
|
|
36
|
+
let apiKey = envVar ? process.env[envVar] : undefined;
|
|
37
|
+
if (!apiKey) {
|
|
38
|
+
apiKey = await resolveProviderToken("opencode");
|
|
39
|
+
}
|
|
40
|
+
if (!apiKey) {
|
|
41
|
+
throw new Error("OpenCode API key is required. Set OPENCODE_API_KEY environment variable or run 'struktur auth set --provider opencode --token <token>'");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (modelName.startsWith("claude-")) {
|
|
45
|
+
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
|
46
|
+
return createAnthropic({
|
|
47
|
+
apiKey,
|
|
48
|
+
baseURL: "https://opencode.ai/zen/v1",
|
|
49
|
+
})(modelName);
|
|
50
|
+
} else if (modelName.startsWith("gemini-")) {
|
|
51
|
+
const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
|
|
52
|
+
return createGoogleGenerativeAI({
|
|
53
|
+
apiKey,
|
|
54
|
+
baseURL: "https://opencode.ai/zen/v1",
|
|
55
|
+
})(modelName);
|
|
56
|
+
} else {
|
|
57
|
+
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
58
|
+
return createOpenAI({
|
|
59
|
+
apiKey,
|
|
60
|
+
baseURL: "https://opencode.ai/zen/v1",
|
|
61
|
+
})(modelName);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
case "openrouter": {
|
|
65
|
+
const { openrouter } = await import("@openrouter/ai-sdk-provider");
|
|
66
|
+
const hashIndex = modelName.indexOf("#");
|
|
67
|
+
const actualModelName = hashIndex >= 0 ? modelName.slice(0, hashIndex) : modelName;
|
|
68
|
+
const preferredProvider = hashIndex >= 0 ? modelName.slice(hashIndex + 1) : undefined;
|
|
69
|
+
|
|
70
|
+
const modelInstance = openrouter(actualModelName);
|
|
71
|
+
|
|
72
|
+
if (preferredProvider) {
|
|
73
|
+
Object.defineProperty(modelInstance, "__openrouter_provider", {
|
|
74
|
+
value: preferredProvider,
|
|
75
|
+
writable: false,
|
|
76
|
+
enumerable: false,
|
|
77
|
+
configurable: false,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return modelInstance;
|
|
82
|
+
}
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(`Unsupported model provider: ${provider}. Supported providers: openai, anthropic, google, opencode, openrouter`);
|
|
85
|
+
}
|
|
86
|
+
};
|