@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.
@@ -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
+ });
@@ -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
- const result = await generateText({
65
- model: request.model as ModelType,
66
- output: Output.object({
67
- schema: schema as GenerateTextParams extends { schema: infer S }
68
- ? S
69
- : never,
70
- name: request.schemaName ?? "extract",
71
- description: request.schemaDescription,
72
- }),
73
- providerOptions: {
74
- openai: {
75
- strictJsonSchema: request.strict ?? false,
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
- system: request.system,
79
- messages: (request.messages ?? [
80
- { role: "user", content: request.user },
81
- ]) as MessageType,
82
- ...(providerOptions ? { providerOptions } : {}),
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
+ };