@independo/inderun-web 0.4.0-rc.1 → 0.4.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.
@@ -4,7 +4,8 @@ import {
4
4
  createInternal,
5
5
  createRateLimited,
6
6
  createTimeout,
7
- createUnavailable
7
+ createUnavailable,
8
+ type IndeRunExceptionParams
8
9
  } from "../../core/errors.js";
9
10
  import type { HostServices, HttpRequest } from "../../core/host.js";
10
11
  import type {
@@ -26,12 +27,26 @@ interface OpenAIStreamEvent {
26
27
  type?: string;
27
28
  delta?: string;
28
29
  response?: OpenAIResponseBody;
29
- error?: { message?: string; type?: string; code?: string };
30
+ error?: OpenAIReportedError;
30
31
  /** Root-level failure fields, as carried by the standalone `error` event. */
31
32
  message?: string;
32
- code?: string;
33
+ code?: unknown;
33
34
  }
34
35
 
36
+ /**
37
+ * A failure reported inside a response body or stream event. `code` is a string
38
+ * on OpenAI and the numeric HTTP status on OpenRouter, so it is read as either.
39
+ */
40
+ interface OpenAIReportedError {
41
+ message?: string | undefined;
42
+ type?: string | undefined;
43
+ code?: unknown;
44
+ /** OpenRouter's typed classification on an untyped (chat-completions-shaped) error event. */
45
+ metadata?: { error_type?: unknown } | undefined;
46
+ }
47
+
48
+ type ErrorParams = Omit<IndeRunExceptionParams, "errorClass" | "message">;
49
+
35
50
  /** Buffers a body stream to text, for error responses that are not event streams. */
36
51
  async function drainToText(body: AsyncIterable<Uint8Array>): Promise<string> {
37
52
  const decoder = new TextDecoder();
@@ -102,11 +117,12 @@ type OpenAIResponseBody = {
102
117
  incomplete_details?: {
103
118
  reason?: unknown;
104
119
  };
105
- error?: {
106
- message?: string;
107
- type?: string;
108
- code?: string;
109
- };
120
+ error?: OpenAIReportedError;
121
+ /**
122
+ * OpenRouter's typed classification of a failed response. It sits beside
123
+ * `error`, whose native `code` collapses many causes into `server_error`.
124
+ */
125
+ error_type?: unknown;
110
126
  };
111
127
 
112
128
  type OpenAIErrorBody = {
@@ -304,7 +320,26 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
304
320
  }
305
321
 
306
322
  const responseBody = parseJson<OpenAIResponseBody>(response.body);
323
+ // A lenient server (OpenRouter among them) can answer 2xx for a run that
324
+ // failed upstream; that is a failure to classify, not an empty answer.
325
+ if (responseBody.status === "failed" || responseBody.status === "cancelled") {
326
+ throw this.mapReportedError(
327
+ responseBody.error,
328
+ `OpenAI Responses run ended with status '${responseBody.status}'.`,
329
+ context,
330
+ responseBody.error_type
331
+ );
332
+ }
333
+
307
334
  const outputText = extractOutputText(responseBody);
335
+ if (outputText === undefined && isRecord(responseBody.error)) {
336
+ throw this.mapReportedError(
337
+ responseBody.error,
338
+ "OpenAI Responses payload reported an error.",
339
+ context,
340
+ responseBody.error_type
341
+ );
342
+ }
308
343
  if (outputText === undefined) {
309
344
  throw createInternal("OpenAI Responses payload did not contain text output.", {
310
345
  providerId: this.id,
@@ -356,7 +391,9 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
356
391
  * - `response.completed` and `response.incomplete` become the terminal done,
357
392
  * with `finalText`, `usage`, and `finishReason` read off the embedded
358
393
  * response object by the same helpers Mode 1 uses — it has the same shape;
359
- * - `response.failed` and `error` become a terminal error;
394
+ * - `response.failed`, `response.error` and `error` become a terminal error,
395
+ * as does an untyped event carrying an `error` object (OpenRouter's
396
+ * documented mid-stream failure shape);
360
397
  * - every other event type is ignored, since the set is open and additive.
361
398
  *
362
399
  * @param request - Canonical IndeRun task request.
@@ -455,7 +492,12 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
455
492
  return;
456
493
  }
457
494
 
458
- if (type === "response.failed" || type === "error") {
495
+ if (
496
+ type === "response.failed" ||
497
+ type === "response.error" ||
498
+ type === "error" ||
499
+ (type === undefined && isRecord(parsed.error))
500
+ ) {
459
501
  yield { kind: "error", error: this.mapStreamError(parsed, context) };
460
502
  return;
461
503
  }
@@ -467,30 +509,79 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
467
509
  }
468
510
 
469
511
  /**
470
- * Maps a `response.failed` / `error` stream event onto the error taxonomy.
471
- * These arrive over an HTTP 200 body, so there is no status code to classify
472
- * from; OpenAI reports rate limiting and auth failures here with the same
473
- * `code` values it uses in unary error bodies.
512
+ * Maps a `response.failed` / `response.error` / `error` stream event onto the
513
+ * error taxonomy. These arrive over an HTTP 200 body, so there is no status
514
+ * code to classify from; the event's own `code` is classified instead.
474
515
  *
475
516
  * Two payload shapes exist and both are accepted: the standalone `error`
476
517
  * event carries `code`/`message` at its root, while `response.failed` nests
477
- * them under `response.error`. The root `type` is the event name rather than
478
- * an error type, so it is never read as one.
518
+ * them under `response.error` and some servers nest them under `error`. The
519
+ * root `type` is the event name rather than an error type, so it is never
520
+ * read as one. OpenRouter's typed classification comes from the embedded
521
+ * response (`response.failed`) or the error's metadata (untyped events).
479
522
  */
480
523
  private mapStreamError(event: OpenAIStreamEvent, context: ProviderStreamContext): unknown {
481
524
  const nested = event.error ?? event.response?.error;
525
+ return this.mapReportedError(
526
+ {
527
+ message: nested?.message ?? event.message,
528
+ type: nested?.type,
529
+ code: nested?.code ?? event.code
530
+ },
531
+ "OpenAI Responses stream reported a failure.",
532
+ context,
533
+ event.response?.error_type ?? nested?.metadata?.error_type
534
+ );
535
+ }
536
+
537
+ /**
538
+ * Classifies a failure reported in a body rather than by the HTTP status: a
539
+ * failed stream event or a 2xx run body whose status is `failed`. The most
540
+ * specific signal wins:
541
+ * 1. OpenRouter's typed `error_type`, classified by the HTTP status it
542
+ * documents for that type (see `openRouterErrorTypeStatus`);
543
+ * 2. an integer `code`, which is an HTTP status (OpenRouter's convention) and
544
+ * is classified exactly as `mapHttpError` classifies that status;
545
+ * 3. a string code, by OpenAI's names.
546
+ * Anything unrecognized is a transient `Unavailable`, since the server did
547
+ * accept the request.
548
+ */
549
+ private mapReportedError(
550
+ reported: OpenAIReportedError | undefined,
551
+ fallbackMessage: string,
552
+ context: RunContext,
553
+ errorType?: unknown
554
+ ): Error {
482
555
  const message =
483
- nested?.message ?? event.message ?? "OpenAI Responses stream reported a failure.";
484
- const code = nested?.code ?? event.code;
485
- const details = { errorType: nested?.type, errorCode: code };
556
+ typeof reported?.message === "string" && reported.message.length > 0
557
+ ? reported.message
558
+ : fallbackMessage;
559
+ const typedStatus = openRouterErrorTypeStatus(errorType);
560
+ const details = {
561
+ errorType: typedStatus !== undefined ? (errorType as string) : reported?.type,
562
+ errorCode: getErrorCode(reported?.code)
563
+ };
486
564
  const params = { providerId: this.id, runId: context.runId, details };
487
565
 
566
+ if (typedStatus !== undefined) {
567
+ return this.errorForStatus(typedStatus, message, params);
568
+ }
569
+
570
+ const code = reported?.code;
571
+ if (typeof code === "number" && Number.isInteger(code)) {
572
+ return this.errorForStatus(code, message, params);
573
+ }
574
+
488
575
  switch (code) {
489
576
  case "rate_limit_exceeded":
490
577
  return createRateLimited(message, { ...params, retryable: true });
491
578
  case "invalid_api_key":
492
579
  case "authentication_error":
493
580
  return createAuthError(message, params);
581
+ // The request itself was rejected; repeating it cannot succeed.
582
+ case "invalid_prompt":
583
+ case "image_content_policy_violation":
584
+ return createInternal(message, params);
494
585
  default:
495
586
  return createUnavailable(message, { ...params, retryable: true });
496
587
  }
@@ -573,12 +664,9 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
573
664
  }
574
665
 
575
666
  /**
576
- * Maps an OpenAI HTTP error response onto the IndeRun error taxonomy:
577
- * - `401` / `403` → `AuthError`
578
- * - `429` → `RateLimited` (honors `Retry-After` when present)
579
- * - `408` / `504` → `Timeout`
580
- * - `409` / `5xx` → `Unavailable`
581
- * - any other non-2xx status → `Internal`
667
+ * Maps an OpenAI HTTP error response onto the IndeRun error taxonomy by its
668
+ * status (see `errorForStatus`), honoring `Retry-After` on whichever status
669
+ * carries it.
582
670
  */
583
671
  private mapHttpError(
584
672
  status: number,
@@ -589,63 +677,70 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
589
677
  ): Error {
590
678
  const error = parseJson<OpenAIErrorBody>(body);
591
679
  const message = getOpenAIErrorMessage(error, status, statusText);
592
- const details = {
593
- status,
594
- statusText,
595
- errorType: getString(error.error?.type),
596
- errorCode: getString(error.error?.code)
680
+ const params: ErrorParams = {
681
+ providerId: this.id,
682
+ runId: context.runId,
683
+ details: {
684
+ status,
685
+ statusText,
686
+ errorType: getString(error.error?.type),
687
+ errorCode: getErrorCode(error.error?.code)
688
+ }
597
689
  };
598
690
 
599
- if (status === 401 || status === 403) {
600
- return createAuthError(message, {
601
- providerId: this.id,
602
- runId: context.runId,
603
- details
604
- });
691
+ return this.errorForStatus(status, message, params, parseRetryAfterMs(headers));
692
+ }
693
+
694
+ /**
695
+ * The status classification shared by HTTP errors and numeric in-body codes:
696
+ * - `401` / `403` → `AuthError`
697
+ * - `402` → `Unavailable`, not retryable: the account is out of credits, so
698
+ * this target cannot serve until someone tops it up. A 402 that carries
699
+ * `Retry-After` is the exception — OpenRouter documents it for its
700
+ * in-flight budget limit, a wait-and-retry case — and is `RateLimited`. Fallback is unaffected
701
+ * either way: the engine moves on to the next target whatever the class.
702
+ * - `429` → `RateLimited`
703
+ * - `408` / `504` → `Timeout`
704
+ * - `409` / `5xx` → `Unavailable`
705
+ * - any other status → `Internal`
706
+ *
707
+ * `retryAfterMs` travels on every retryable class; it is absent for numeric
708
+ * in-body codes, which have no header to read it from.
709
+ */
710
+ private errorForStatus(
711
+ status: number,
712
+ message: string,
713
+ params: ErrorParams,
714
+ retryAfterMs?: number
715
+ ): Error {
716
+ const retry: ErrorParams = { ...params, retryable: true };
717
+ if (retryAfterMs !== undefined) {
718
+ retry.retryAfterMs = retryAfterMs;
605
719
  }
606
720
 
607
- if (status === 429) {
608
- const retryAfterMs = parseRetryAfterMs(headers);
609
- const params = {
610
- providerId: this.id,
611
- runId: context.runId,
612
- retryable: true,
613
- details
614
- };
721
+ if (status === 401 || status === 403) {
722
+ return createAuthError(message, params);
723
+ }
615
724
 
616
- if (retryAfterMs !== undefined) {
617
- return createRateLimited(message, {
618
- ...params,
619
- retryAfterMs
620
- });
621
- }
725
+ if (status === 402) {
726
+ return retryAfterMs === undefined
727
+ ? createUnavailable(message, { ...params, retryable: false })
728
+ : createRateLimited(message, retry);
729
+ }
622
730
 
623
- return createRateLimited(message, params);
731
+ if (status === 429) {
732
+ return createRateLimited(message, retry);
624
733
  }
625
734
 
626
735
  if (status === 408 || status === 504) {
627
- return createTimeout(message, {
628
- providerId: this.id,
629
- runId: context.runId,
630
- retryable: true,
631
- details
632
- });
736
+ return createTimeout(message, retry);
633
737
  }
634
738
 
635
739
  if (status === 409 || status >= 500) {
636
- return createUnavailable(message, {
637
- providerId: this.id,
638
- runId: context.runId,
639
- retryable: true,
640
- details
641
- });
740
+ return createUnavailable(message, retry);
642
741
  }
643
742
 
644
- return createInternal(message, {
645
- providerId: this.id,
646
- runId: context.runId,
647
- details
648
- });
743
+ return createInternal(message, params);
649
744
  }
650
745
  }
651
746
 
@@ -784,3 +879,51 @@ function isRecord(value: unknown): value is Record<string, unknown> {
784
879
  function getString(value: unknown): string | undefined {
785
880
  return typeof value === "string" ? value : undefined;
786
881
  }
882
+
883
+ /**
884
+ * The HTTP status OpenRouter documents for each typed `error_type`, so a typed
885
+ * failure is classified exactly like the equivalent HTTP error. Refusals and
886
+ * content-policy violations are documented as 403 but are not credential
887
+ * failures, so they classify as a rejected request (400). Unknown types return
888
+ * `undefined` and the native `code` decides instead.
889
+ */
890
+ function openRouterErrorTypeStatus(errorType: unknown): number | undefined {
891
+ return typeof errorType === "string" && Object.hasOwn(OPENROUTER_ERROR_TYPE_STATUS, errorType)
892
+ ? OPENROUTER_ERROR_TYPE_STATUS[errorType]
893
+ : undefined;
894
+ }
895
+
896
+ const OPENROUTER_ERROR_TYPE_STATUS: Readonly<Record<string, number>> = {
897
+ authentication: 401,
898
+ payment_required: 402,
899
+ permission_denied: 403,
900
+ rate_limit_exceeded: 429,
901
+ timeout: 504,
902
+ provider_unavailable: 502,
903
+ provider_overloaded: 503,
904
+ server: 500,
905
+ unmapped: 500,
906
+ not_found: 404,
907
+ image_not_found: 404,
908
+ precondition_failed: 412,
909
+ payload_too_large: 413,
910
+ unprocessable: 422,
911
+ content_policy_violation: 400,
912
+ refusal: 400,
913
+ context_length_exceeded: 400,
914
+ max_tokens_exceeded: 400,
915
+ token_limit_exceeded: 400,
916
+ string_too_long: 400,
917
+ invalid_request: 400,
918
+ invalid_prompt: 400,
919
+ invalid_image: 400,
920
+ image_too_large: 400,
921
+ image_too_small: 400,
922
+ unsupported_image_format: 400,
923
+ image_download_failed: 400
924
+ };
925
+
926
+ /** An error `code` as a detail string, whether the server sent a name or a numeric status. */
927
+ function getErrorCode(value: unknown): string | undefined {
928
+ return typeof value === "number" ? String(value) : getString(value);
929
+ }
package/src/web-sdk.ts CHANGED
@@ -42,7 +42,8 @@ export interface CreateIndeRunWebOptions {
42
42
  */
43
43
  hostServices?: CreateBrowserHostServicesOptions;
44
44
  /**
45
- * Explicit opt-in for calling the public OpenAI endpoint directly from this Web SDK factory.
45
+ * Explicit opt-in for calling a public Responses endpoint directly from this Web SDK factory —
46
+ * OpenAI's or OpenRouter's — with browser-held credentials.
46
47
  *
47
48
  * Browser apps should leave this unset and use a proxy endpoint with `openAI.auth = "none"` instead.
48
49
  * Direct calls can expose credentials to client-side code and are appropriate only for controlled
@@ -118,16 +119,16 @@ function assertSafeOpenAIEndpoint(
118
119
  ): void {
119
120
  const endpointUrl = openAI.endpointUrl ?? DEFAULT_OPENAI_RESPONSES_ENDPOINT;
120
121
  const auth = openAI.auth ?? "authContextRef";
121
- const isDirectOpenAIEndpoint = isOpenAIResponsesEndpoint(endpointUrl);
122
+ const isDirectPublicEndpoint = isPublicResponsesEndpoint(endpointUrl);
122
123
 
123
- if (isDirectOpenAIEndpoint && auth !== "none" && !allowDirectOpenAIEndpoint) {
124
+ if (isDirectPublicEndpoint && auth !== "none" && !allowDirectOpenAIEndpoint) {
124
125
  throw new Error(
125
126
  'createIndeRunWeb is proxy-first for browser safety. Configure openAI.endpointUrl to a server-side proxy with auth: "none", or set allowDirectOpenAIEndpoint: true for controlled direct OpenAI calls.'
126
127
  );
127
128
  }
128
129
  }
129
130
 
130
- function isOpenAIResponsesEndpoint(endpointUrl: string): boolean {
131
+ function isPublicResponsesEndpoint(endpointUrl: string): boolean {
131
132
  let parsed: URL;
132
133
  try {
133
134
  parsed = new URL(endpointUrl);
@@ -136,5 +137,15 @@ function isOpenAIResponsesEndpoint(endpointUrl: string): boolean {
136
137
  }
137
138
 
138
139
  const pathname = parsed.pathname.replace(/\/+$/u, "");
139
- return parsed.hostname.toLowerCase() === "api.openai.com" && pathname === "/v1/responses";
140
+ const endpoint = `${parsed.hostname.toLowerCase()}${pathname}`;
141
+ return PUBLIC_RESPONSES_ENDPOINTS.includes(endpoint);
140
142
  }
143
+
144
+ /**
145
+ * Hosted Responses endpoints whose credentials are developer-owned API keys, which the
146
+ * factory keeps out of browser code unless `allowDirectOpenAIEndpoint` says otherwise.
147
+ */
148
+ const PUBLIC_RESPONSES_ENDPOINTS = [
149
+ "api.openai.com/v1/responses",
150
+ "openrouter.ai/api/v1/responses"
151
+ ];