@independo/inderun-web 0.3.3 → 0.4.0-rc.2

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.
Files changed (52) hide show
  1. package/README.md +46 -9
  2. package/dist/core/engine.d.ts +26 -2
  3. package/dist/core/engine.d.ts.map +1 -1
  4. package/dist/core/engine.js +78 -34
  5. package/dist/core/errors.d.ts +13 -1
  6. package/dist/core/errors.d.ts.map +1 -1
  7. package/dist/core/errors.js +32 -0
  8. package/dist/core/model-profiles.d.ts +34 -0
  9. package/dist/core/model-profiles.d.ts.map +1 -0
  10. package/dist/core/model-profiles.js +49 -0
  11. package/dist/core/provenance.d.ts +32 -0
  12. package/dist/core/provenance.d.ts.map +1 -0
  13. package/dist/core/provenance.js +71 -0
  14. package/dist/core/provider.d.ts +13 -0
  15. package/dist/core/provider.d.ts.map +1 -1
  16. package/dist/core/route-planner.d.ts +1 -1
  17. package/dist/core/route-planner.d.ts.map +1 -1
  18. package/dist/core/route-planner.js +2 -1
  19. package/dist/core/router.d.ts +4 -3
  20. package/dist/core/router.d.ts.map +1 -1
  21. package/dist/core/router.js +4 -3
  22. package/dist/facade/text.d.ts +123 -0
  23. package/dist/facade/text.d.ts.map +1 -0
  24. package/dist/facade/text.js +234 -0
  25. package/dist/index.d.ts +36 -45
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +28 -38
  28. package/dist/provider.d.ts +34 -0
  29. package/dist/provider.d.ts.map +1 -0
  30. package/dist/provider.js +33 -0
  31. package/dist/providers/onnx/provider.d.ts.map +1 -1
  32. package/dist/providers/onnx/provider.js +1 -0
  33. package/dist/providers/openai/provider.d.ts +41 -11
  34. package/dist/providers/openai/provider.d.ts.map +1 -1
  35. package/dist/providers/openai/provider.js +168 -58
  36. package/dist/web-sdk.d.ts +21 -3
  37. package/dist/web-sdk.d.ts.map +1 -1
  38. package/dist/web-sdk.js +23 -6
  39. package/package.json +7 -3
  40. package/src/core/engine.ts +114 -35
  41. package/src/core/errors.ts +35 -1
  42. package/src/core/model-profiles.ts +70 -0
  43. package/src/core/provenance.ts +90 -0
  44. package/src/core/provider.ts +13 -0
  45. package/src/core/route-planner.ts +3 -1
  46. package/src/core/router.ts +12 -3
  47. package/src/facade/text.ts +349 -0
  48. package/src/index.ts +49 -95
  49. package/src/provider.ts +77 -0
  50. package/src/providers/onnx/provider.ts +1 -0
  51. package/src/providers/openai/provider.ts +232 -68
  52. package/src/web-sdk.ts +47 -10
@@ -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();
@@ -90,6 +105,7 @@ export interface OpenAIProviderOptions {
90
105
  }
91
106
 
92
107
  type OpenAIResponseBody = {
108
+ model?: unknown;
93
109
  output_text?: unknown;
94
110
  output?: unknown;
95
111
  status?: unknown;
@@ -101,11 +117,12 @@ type OpenAIResponseBody = {
101
117
  incomplete_details?: {
102
118
  reason?: unknown;
103
119
  };
104
- error?: {
105
- message?: string;
106
- type?: string;
107
- code?: string;
108
- };
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;
109
126
  };
110
127
 
111
128
  type OpenAIErrorBody = {
@@ -146,6 +163,7 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
146
163
  return {
147
164
  id: this.id,
148
165
  type: "cloud",
166
+ model: this.options.model,
149
167
  transport: "http",
150
168
  streamingStyle: "tokens",
151
169
  supports: {
@@ -302,7 +320,26 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
302
320
  }
303
321
 
304
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
+
305
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
+ }
306
343
  if (outputText === undefined) {
307
344
  throw createInternal("OpenAI Responses payload did not contain text output.", {
308
345
  providerId: this.id,
@@ -324,6 +361,11 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
324
361
  }
325
362
  };
326
363
 
364
+ const reportedModel = getReportedModel(responseBody);
365
+ if (reportedModel !== undefined) {
366
+ result.telemetry.reportedModel = reportedModel;
367
+ }
368
+
327
369
  const usage = getUsage(responseBody);
328
370
  if (usage) {
329
371
  result.usage = usage;
@@ -349,7 +391,9 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
349
391
  * - `response.completed` and `response.incomplete` become the terminal done,
350
392
  * with `finalText`, `usage`, and `finishReason` read off the embedded
351
393
  * response object by the same helpers Mode 1 uses — it has the same shape;
352
- * - `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);
353
397
  * - every other event type is ignored, since the set is open and additive.
354
398
  *
355
399
  * @param request - Canonical IndeRun task request.
@@ -440,11 +484,20 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
440
484
  if (usage) {
441
485
  done.usage = usage;
442
486
  }
487
+ const reportedModel = getReportedModel(body);
488
+ if (reportedModel !== undefined) {
489
+ done.reportedModel = reportedModel;
490
+ }
443
491
  yield done;
444
492
  return;
445
493
  }
446
494
 
447
- 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
+ ) {
448
501
  yield { kind: "error", error: this.mapStreamError(parsed, context) };
449
502
  return;
450
503
  }
@@ -456,30 +509,79 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
456
509
  }
457
510
 
458
511
  /**
459
- * Maps a `response.failed` / `error` stream event onto the error taxonomy.
460
- * These arrive over an HTTP 200 body, so there is no status code to classify
461
- * from; OpenAI reports rate limiting and auth failures here with the same
462
- * `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.
463
515
  *
464
516
  * Two payload shapes exist and both are accepted: the standalone `error`
465
517
  * event carries `code`/`message` at its root, while `response.failed` nests
466
- * them under `response.error`. The root `type` is the event name rather than
467
- * 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).
468
522
  */
469
523
  private mapStreamError(event: OpenAIStreamEvent, context: ProviderStreamContext): unknown {
470
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 {
471
555
  const message =
472
- nested?.message ?? event.message ?? "OpenAI Responses stream reported a failure.";
473
- const code = nested?.code ?? event.code;
474
- 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
+ };
475
564
  const params = { providerId: this.id, runId: context.runId, details };
476
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
+
477
575
  switch (code) {
478
576
  case "rate_limit_exceeded":
479
577
  return createRateLimited(message, { ...params, retryable: true });
480
578
  case "invalid_api_key":
481
579
  case "authentication_error":
482
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);
483
585
  default:
484
586
  return createUnavailable(message, { ...params, retryable: true });
485
587
  }
@@ -562,12 +664,9 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
562
664
  }
563
665
 
564
666
  /**
565
- * Maps an OpenAI HTTP error response onto the IndeRun error taxonomy:
566
- * - `401` / `403` → `AuthError`
567
- * - `429` → `RateLimited` (honors `Retry-After` when present)
568
- * - `408` / `504` → `Timeout`
569
- * - `409` / `5xx` → `Unavailable`
570
- * - 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.
571
670
  */
572
671
  private mapHttpError(
573
672
  status: number,
@@ -578,63 +677,70 @@ export class OpenAIResponsesProvider implements ProviderAdapter {
578
677
  ): Error {
579
678
  const error = parseJson<OpenAIErrorBody>(body);
580
679
  const message = getOpenAIErrorMessage(error, status, statusText);
581
- const details = {
582
- status,
583
- statusText,
584
- errorType: getString(error.error?.type),
585
- 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
+ }
586
689
  };
587
690
 
588
- if (status === 401 || status === 403) {
589
- return createAuthError(message, {
590
- providerId: this.id,
591
- runId: context.runId,
592
- details
593
- });
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;
594
719
  }
595
720
 
596
- if (status === 429) {
597
- const retryAfterMs = parseRetryAfterMs(headers);
598
- const params = {
599
- providerId: this.id,
600
- runId: context.runId,
601
- retryable: true,
602
- details
603
- };
721
+ if (status === 401 || status === 403) {
722
+ return createAuthError(message, params);
723
+ }
604
724
 
605
- if (retryAfterMs !== undefined) {
606
- return createRateLimited(message, {
607
- ...params,
608
- retryAfterMs
609
- });
610
- }
725
+ if (status === 402) {
726
+ return retryAfterMs === undefined
727
+ ? createUnavailable(message, { ...params, retryable: false })
728
+ : createRateLimited(message, retry);
729
+ }
611
730
 
612
- return createRateLimited(message, params);
731
+ if (status === 429) {
732
+ return createRateLimited(message, retry);
613
733
  }
614
734
 
615
735
  if (status === 408 || status === 504) {
616
- return createTimeout(message, {
617
- providerId: this.id,
618
- runId: context.runId,
619
- retryable: true,
620
- details
621
- });
736
+ return createTimeout(message, retry);
622
737
  }
623
738
 
624
739
  if (status === 409 || status >= 500) {
625
- return createUnavailable(message, {
626
- providerId: this.id,
627
- runId: context.runId,
628
- retryable: true,
629
- details
630
- });
740
+ return createUnavailable(message, retry);
631
741
  }
632
742
 
633
- return createInternal(message, {
634
- providerId: this.id,
635
- runId: context.runId,
636
- details
637
- });
743
+ return createInternal(message, params);
638
744
  }
639
745
  }
640
746
 
@@ -686,6 +792,16 @@ function getFinishReason(response: OpenAIResponseBody): TaskResult["finishReason
686
792
  return "stop";
687
793
  }
688
794
 
795
+ /**
796
+ * The model id the Responses API echoes on the response object. It names the model that
797
+ * served the request, which can be a dated snapshot of the configured alias.
798
+ */
799
+ function getReportedModel(responseBody: OpenAIResponseBody): string | undefined {
800
+ return typeof responseBody.model === "string" && responseBody.model.length > 0
801
+ ? responseBody.model
802
+ : undefined;
803
+ }
804
+
689
805
  function getUsage(response: OpenAIResponseBody): TaskResult["usage"] | undefined {
690
806
  const usage: TaskResult["usage"] = {};
691
807
 
@@ -763,3 +879,51 @@ function isRecord(value: unknown): value is Record<string, unknown> {
763
879
  function getString(value: unknown): string | undefined {
764
880
  return typeof value === "string" ? value : undefined;
765
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
@@ -3,6 +3,7 @@ import {
3
3
  type CreateBrowserHostServicesOptions
4
4
  } from "./core/browser-host.js";
5
5
  import { IndeRun } from "./core/engine.js";
6
+ import type { ModelProfiles } from "./core/model-profiles.js";
6
7
  import { OnnxRuntimeWebProvider, type OnnxProviderOptions } from "./providers/onnx/provider.js";
7
8
  import {
8
9
  DEFAULT_OPENAI_RESPONSES_ENDPOINT,
@@ -20,9 +21,11 @@ import {
20
21
  */
21
22
  export interface CreateIndeRunWebOptions {
22
23
  /**
23
- * OpenAI Responses provider configuration registered by the factory.
24
+ * OpenAI Responses provider configuration registered by the factory. One target is
25
+ * registered per entry in `models`, with id `<id>:<model>` (`id` defaults to
26
+ * `"openai"`), so model profiles can list each model separately.
24
27
  */
25
- openAI?: OpenAIProviderOptions;
28
+ openAI?: OpenAIFactoryOptions;
26
29
  /**
27
30
  * Web ONNX Runtime provider configuration registered by the factory, for developer-supplied
28
31
  * local models. Registering it is what makes `constraints.privacy = "local_required"` routable.
@@ -39,15 +42,31 @@ export interface CreateIndeRunWebOptions {
39
42
  */
40
43
  hostServices?: CreateBrowserHostServicesOptions;
41
44
  /**
42
- * 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.
43
47
  *
44
48
  * Browser apps should leave this unset and use a proxy endpoint with `openAI.auth = "none"` instead.
45
49
  * Direct calls can expose credentials to client-side code and are appropriate only for controlled
46
50
  * non-production environments.
47
51
  */
48
52
  allowDirectOpenAIEndpoint?: boolean;
53
+ /**
54
+ * Named model profiles requests can route within. Each maps a profile name to an
55
+ * ordered list of target ids, for example `{ smart: ["openai:gpt-5.2"] }`.
56
+ */
57
+ modelProfiles?: ModelProfiles;
49
58
  }
50
59
 
60
+ /**
61
+ * OpenAI options for {@link createIndeRunWeb}: the provider options with `models` in
62
+ * place of the single `model`. The first model is not special; every model becomes
63
+ * its own target.
64
+ */
65
+ export type OpenAIFactoryOptions = Omit<OpenAIProviderOptions, "model"> & {
66
+ /** OpenAI model ids to register, one target each. */
67
+ models: readonly string[];
68
+ };
69
+
51
70
  /**
52
71
  * Creates a Web SDK instance with the configured providers registered.
53
72
  *
@@ -72,7 +91,13 @@ export function createIndeRunWeb(options: CreateIndeRunWebOptions): IndeRun {
72
91
 
73
92
  if (options.openAI) {
74
93
  assertSafeOpenAIEndpoint(options.openAI, options.allowDirectOpenAIEndpoint);
75
- registry.register(new OpenAIResponsesProvider(options.openAI));
94
+ const { models, id = "openai", ...shared } = options.openAI;
95
+ if (models.length === 0) {
96
+ throw new Error("createIndeRunWeb requires openAI.models to list at least one model.");
97
+ }
98
+ for (const model of models) {
99
+ registry.register(new OpenAIResponsesProvider({ ...shared, id: `${id}:${model}`, model }));
100
+ }
76
101
  }
77
102
 
78
103
  if (options.onnx) {
@@ -83,25 +108,27 @@ export function createIndeRunWeb(options: CreateIndeRunWebOptions): IndeRun {
83
108
  registry.register(new SystemModelWebProvider(options.systemModel));
84
109
  }
85
110
 
86
- return new IndeRun(registry, createBrowserHostServices(options.hostServices));
111
+ return new IndeRun(registry, createBrowserHostServices(options.hostServices), {
112
+ ...(options.modelProfiles ? { modelProfiles: options.modelProfiles } : {})
113
+ });
87
114
  }
88
115
 
89
116
  function assertSafeOpenAIEndpoint(
90
- openAI: OpenAIProviderOptions,
117
+ openAI: OpenAIFactoryOptions,
91
118
  allowDirectOpenAIEndpoint?: boolean
92
119
  ): void {
93
120
  const endpointUrl = openAI.endpointUrl ?? DEFAULT_OPENAI_RESPONSES_ENDPOINT;
94
121
  const auth = openAI.auth ?? "authContextRef";
95
- const isDirectOpenAIEndpoint = isOpenAIResponsesEndpoint(endpointUrl);
122
+ const isDirectPublicEndpoint = isPublicResponsesEndpoint(endpointUrl);
96
123
 
97
- if (isDirectOpenAIEndpoint && auth !== "none" && !allowDirectOpenAIEndpoint) {
124
+ if (isDirectPublicEndpoint && auth !== "none" && !allowDirectOpenAIEndpoint) {
98
125
  throw new Error(
99
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.'
100
127
  );
101
128
  }
102
129
  }
103
130
 
104
- function isOpenAIResponsesEndpoint(endpointUrl: string): boolean {
131
+ function isPublicResponsesEndpoint(endpointUrl: string): boolean {
105
132
  let parsed: URL;
106
133
  try {
107
134
  parsed = new URL(endpointUrl);
@@ -110,5 +137,15 @@ function isOpenAIResponsesEndpoint(endpointUrl: string): boolean {
110
137
  }
111
138
 
112
139
  const pathname = parsed.pathname.replace(/\/+$/u, "");
113
- return parsed.hostname.toLowerCase() === "api.openai.com" && pathname === "/v1/responses";
140
+ const endpoint = `${parsed.hostname.toLowerCase()}${pathname}`;
141
+ return PUBLIC_RESPONSES_ENDPOINTS.includes(endpoint);
114
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
+ ];