@open-everywhere/adapters 0.7.0 → 0.8.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.
Files changed (79) hide show
  1. package/agent/aws/sso.ts +0 -2
  2. package/agent/instrumentation.ts +67 -4
  3. package/agent/model/approval-judge.ts +3 -3
  4. package/agent/model/dream-judge.ts +8 -6
  5. package/agent/model/gateway-probe.ts +97 -11
  6. package/agent/model/gateway-vision.ts +10 -6
  7. package/agent/model/gateway.ts +448 -34
  8. package/agent/model/meeting-gateway.ts +37 -7
  9. package/agent/model/model-config-check.ts +243 -0
  10. package/agent/model/target-url.ts +38 -0
  11. package/agent/model/transcription-routes.ts +18 -4
  12. package/agent/oe-oauth/errors.ts +1 -1
  13. package/agent/oe-oauth/token-vault.ts +99 -29
  14. package/agent/oe-oauth/user-auth-flow.ts +4 -3
  15. package/agent/operator-holders.ts +47 -0
  16. package/agent/postgres/neon.ts +1 -1
  17. package/dist/agent/aws/sso.d.ts +1 -2
  18. package/dist/agent/aws/sso.d.ts.map +1 -1
  19. package/dist/agent/aws/sso.js +0 -2
  20. package/dist/agent/aws/sso.js.map +1 -1
  21. package/dist/agent/instrumentation.d.ts +23 -1
  22. package/dist/agent/instrumentation.d.ts.map +1 -1
  23. package/dist/agent/instrumentation.js +59 -3
  24. package/dist/agent/instrumentation.js.map +1 -1
  25. package/dist/agent/model/approval-judge.d.ts.map +1 -1
  26. package/dist/agent/model/approval-judge.js +3 -3
  27. package/dist/agent/model/approval-judge.js.map +1 -1
  28. package/dist/agent/model/dream-judge.d.ts +2 -0
  29. package/dist/agent/model/dream-judge.d.ts.map +1 -1
  30. package/dist/agent/model/dream-judge.js +6 -4
  31. package/dist/agent/model/dream-judge.js.map +1 -1
  32. package/dist/agent/model/gateway-probe.d.ts +22 -4
  33. package/dist/agent/model/gateway-probe.d.ts.map +1 -1
  34. package/dist/agent/model/gateway-probe.js +78 -11
  35. package/dist/agent/model/gateway-probe.js.map +1 -1
  36. package/dist/agent/model/gateway-vision.d.ts +4 -2
  37. package/dist/agent/model/gateway-vision.d.ts.map +1 -1
  38. package/dist/agent/model/gateway-vision.js +6 -4
  39. package/dist/agent/model/gateway-vision.js.map +1 -1
  40. package/dist/agent/model/gateway.d.ts +85 -12
  41. package/dist/agent/model/gateway.d.ts.map +1 -1
  42. package/dist/agent/model/gateway.js +418 -30
  43. package/dist/agent/model/gateway.js.map +1 -1
  44. package/dist/agent/model/meeting-gateway.d.ts +8 -2
  45. package/dist/agent/model/meeting-gateway.d.ts.map +1 -1
  46. package/dist/agent/model/meeting-gateway.js +29 -7
  47. package/dist/agent/model/meeting-gateway.js.map +1 -1
  48. package/dist/agent/model/model-config-check.d.ts +57 -0
  49. package/dist/agent/model/model-config-check.d.ts.map +1 -0
  50. package/dist/agent/model/model-config-check.js +207 -0
  51. package/dist/agent/model/model-config-check.js.map +1 -0
  52. package/dist/agent/model/target-url.d.ts +10 -0
  53. package/dist/agent/model/target-url.d.ts.map +1 -0
  54. package/dist/agent/model/target-url.js +32 -0
  55. package/dist/agent/model/target-url.js.map +1 -0
  56. package/dist/agent/model/transcription-routes.d.ts +3 -0
  57. package/dist/agent/model/transcription-routes.d.ts.map +1 -1
  58. package/dist/agent/model/transcription-routes.js +16 -4
  59. package/dist/agent/model/transcription-routes.js.map +1 -1
  60. package/dist/agent/oe-oauth/errors.d.ts +0 -7
  61. package/dist/agent/oe-oauth/errors.d.ts.map +1 -1
  62. package/dist/agent/oe-oauth/errors.js +1 -1
  63. package/dist/agent/oe-oauth/errors.js.map +1 -1
  64. package/dist/agent/oe-oauth/token-vault.d.ts +4 -3
  65. package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -1
  66. package/dist/agent/oe-oauth/token-vault.js +88 -24
  67. package/dist/agent/oe-oauth/token-vault.js.map +1 -1
  68. package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -1
  69. package/dist/agent/oe-oauth/user-auth-flow.js +3 -2
  70. package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -1
  71. package/dist/agent/operator-holders.d.ts +13 -0
  72. package/dist/agent/operator-holders.d.ts.map +1 -0
  73. package/dist/agent/operator-holders.js +40 -0
  74. package/dist/agent/operator-holders.js.map +1 -0
  75. package/dist/agent/postgres/neon.d.ts +0 -15
  76. package/dist/agent/postgres/neon.d.ts.map +1 -1
  77. package/dist/agent/postgres/neon.js +1 -1
  78. package/dist/agent/postgres/neon.js.map +1 -1
  79. package/package.json +19 -2
package/agent/aws/sso.ts CHANGED
@@ -18,8 +18,6 @@ import { makePkce, makeState } from "../oe-oauth/pkce.js";
18
18
  /** A `fetch`-shaped transport — the DI seam the offline test injects (defaults to global fetch). */
19
19
  export type FetchLike = typeof fetch;
20
20
 
21
- // Core owns the canonical const; re-exported here so this module's existing consumers keep resolving it.
22
- export { OE_AWS_SSO_CALLBACK_PATH };
23
21
 
24
22
  /** Default OAuth scopes for the Identity Center link. `openid` is LOAD-BEARING, not decoration: it is
25
23
  * what makes the token response carry an id-token, and the id-token's `sub` is the only statement of
@@ -10,14 +10,16 @@
10
10
  // every environment. Without it, eve records content for public conversations and in
11
11
  // development. A destination cannot restore what this ceiling removes.
12
12
  // - `otlpDestination` (the app's `otlp` slot): the consumer's OTLP collector, from the standard
13
- // `OTEL_EXPORTER_OTLP_*` variables, as an eve destination. With no endpoint it is disabled.
13
+ // `OTEL_EXPORTER_OTLP_*` variables, and Vercel's OTel collector, from the `VERCEL_OTEL_ENDPOINTS*`
14
+ // variables the platform sets, as one eve destination. With neither, it is disabled.
14
15
  //
15
16
  // `scripts/instrumentation-hosted-boot-test.ts` boots both slots through eve's registration path
16
17
  // with the env Vercel creates.
17
18
 
18
19
  import { disableInstrumentation } from "eve/instrumentation";
19
- import { otel, otelIntegration, type OtelDeclaration, type SpanExporter } from "eve/instrumentation/otel";
20
+ import { otel, otelIntegration, type OtelDeclaration, type SpanExporter, type SpanProcessor } from "eve/instrumentation/otel";
20
21
  import { OTLPHttpJsonTraceExporter, OTLPHttpProtoTraceExporter } from "@vercel/otel";
22
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
21
23
  import { installLogShipping, initTelemetry } from "@open-everywhere/core/agent/adapter-api.js";
22
24
 
23
25
  /** Metadata only: emit the span, record neither content direction. */
@@ -95,7 +97,68 @@ function otlpExporter(target: OtlpTarget): SpanExporter {
95
97
  return target.protocol === "http/json" ? new OTLPHttpJsonTraceExporter(config) : new OTLPHttpProtoTraceExporter(config);
96
98
  }
97
99
 
100
+ /**
101
+ * Vercel's OTel collector (OPE-2358). On Vercel, an OpenTelemetry integration from the Vercel
102
+ * Marketplace receives spans through a collector on the function's own host. The platform names it
103
+ * with `VERCEL_OTEL_ENDPOINTS`, `VERCEL_OTEL_ENDPOINTS_PORT` and `VERCEL_OTEL_ENDPOINTS_PROTOCOL`.
104
+ * `registerOTel`'s `"auto"` span processor exported to it; eve's pipeline passes no `"auto"`, so
105
+ * this destination replaces it. The URL shape, the port default and the protocol rule are
106
+ * `@vercel/otel`'s own: `http/protobuf` selects protobuf and every other value selects JSON.
107
+ * `undefined` when the platform set no collector.
108
+ */
109
+ export function resolveVercelCollectorTarget(env: NodeJS.ProcessEnv = process.env): OtlpTarget | undefined {
110
+ if (!env.VERCEL_OTEL_ENDPOINTS) return undefined;
111
+ const port = env.VERCEL_OTEL_ENDPOINTS_PORT || "4318";
112
+ const protocol: OtlpProtocol = (env.VERCEL_OTEL_ENDPOINTS_PROTOCOL || "http/protobuf") === "http/protobuf" ? "http/protobuf" : "http/json";
113
+ return { url: `http://localhost:${port}/v1/traces`, protocol, headers: {} };
114
+ }
115
+
116
+ /** The key under which the Vercel runtime publishes the current request's context. */
117
+ const VERCEL_REQUEST_CONTEXT = Symbol.for("@vercel/request-context");
118
+
119
+ interface VercelRequestContext {
120
+ readonly telemetry?: { readonly traceDrains?: readonly unknown[] };
121
+ }
122
+
123
+ /**
124
+ * True when the current Vercel request reports trace drains. The runtime then delivers spans to
125
+ * those drains itself, from eve's Agent Runs export (`telemetry.reportSpans`), so the collector
126
+ * would deliver every span a second time. `@vercel/otel` skips its collector on the same signal.
127
+ * An app that turns Agent Runs off then sends nothing to its trace drains; this destination does not
128
+ * cover that case.
129
+ */
130
+ export function vercelTraceDrainsConfigured(): boolean {
131
+ const store = (globalThis as Record<symbol, { get?: () => VercelRequestContext | undefined } | undefined>)[VERCEL_REQUEST_CONTEXT];
132
+ return (store?.get?.()?.telemetry?.traceDrains?.length ?? 0) > 0;
133
+ }
134
+
135
+ /** Wrap the collector's processor so that it skips every span while trace drains are configured. */
136
+ function skipWhenTraceDrains(inner: BatchSpanProcessor): SpanProcessor {
137
+ return {
138
+ onStart: (span, context) => {
139
+ if (!vercelTraceDrainsConfigured()) inner.onStart(span as never, context as never);
140
+ },
141
+ onEnd: (span) => {
142
+ if (!vercelTraceDrainsConfigured()) inner.onEnd(span as never);
143
+ },
144
+ forceFlush: () => inner.forceFlush(),
145
+ shutdown: () => inner.shutdown(),
146
+ };
147
+ }
148
+
98
149
  const OTLP_TARGET = resolveOtlpTarget();
150
+ const VERCEL_COLLECTOR_TARGET = resolveVercelCollectorTarget();
99
151
 
100
- /** The consumer's OTLP collector as an eve destination, or disabled when no endpoint is set. */
101
- export const otlpDestination = OTLP_TARGET === undefined ? disableInstrumentation() : otelIntegration({ traceExporter: otlpExporter(OTLP_TARGET) });
152
+ /**
153
+ * The consumer's OTLP collector and Vercel's OTel collector, as ONE eve destination: the `otlp` slot
154
+ * file every app already has carries both, so a scaffolded app needs no new file. Disabled when
155
+ * neither is configured. Both obey the `otel` slot's trace policy.
156
+ */
157
+ export const otlpDestination =
158
+ OTLP_TARGET === undefined && VERCEL_COLLECTOR_TARGET === undefined
159
+ ? disableInstrumentation()
160
+ : otelIntegration({
161
+ ...(OTLP_TARGET === undefined ? {} : { traceExporter: otlpExporter(OTLP_TARGET) }),
162
+ spanProcessors:
163
+ VERCEL_COLLECTOR_TARGET === undefined ? [] : [skipWhenTraceDrains(new BatchSpanProcessor(otlpExporter(VERCEL_COLLECTOR_TARGET) as never))],
164
+ });
@@ -1,13 +1,13 @@
1
1
  // Gateway transport for the approval judge — core owns the seam; this owns the vendor call on the fixed
2
2
  // `judge` model role. NO scope routing: the judge must not inherit whatever a domain routed its seat onto.
3
3
 
4
- import { gateway, generateText } from "ai";
4
+ import { generateText } from "ai";
5
5
  import {
6
6
  type ApprovalJudgeCompletion,
7
7
  type JudgePass,
8
8
  registerApprovalJudgeCompletion,
9
9
  } from "@open-everywhere/core/agent/adapter-api.js";
10
- import { applyGatewayOptions, type GatewayRoutedDefaults } from "./gateway.js";
10
+ import { applyGatewayOptions, gatewayLanguageModel, type GatewayRoutedDefaults } from "./gateway.js";
11
11
 
12
12
  /**
13
13
  * Output budget for the scrutiny pass. A reasoning model spends reasoning tokens INSIDE the budget, so a
@@ -37,7 +37,7 @@ function gatewayApprovalJudgeCompletion(defaults: GatewayRoutedDefaults): Approv
37
37
  return async (prompt, pass) => {
38
38
  const s = approvalJudgeCallSettings(pass, defaults);
39
39
  const { text } = await generateText({
40
- model: gateway(s.id),
40
+ model: gatewayLanguageModel(s.id, defaults.via),
41
41
  prompt,
42
42
  ...(s.maxOutputTokens !== undefined ? { maxOutputTokens: s.maxOutputTokens } : {}),
43
43
  ...(s.providerOptions !== undefined
@@ -1,7 +1,7 @@
1
1
  // Gateway transport for the dream judge — core owns the seam; this owns one completion on the `utility`
2
2
  // role. The SCOPE locates the route: a channel key, a silo's owning domain, else `org`. It FAILS QUIET.
3
3
 
4
- import { gateway, generateText } from "ai";
4
+ import { generateText } from "ai";
5
5
  import {
6
6
  type ModelRoute,
7
7
  modelRouteFor,
@@ -16,7 +16,7 @@ import {
16
16
  registerFailureFacetJudge,
17
17
  stampModelCall,
18
18
  } from "@open-everywhere/core/agent/adapter-api.js";
19
- import { applyGatewayOptions, type GatewayRoutedDefaults } from "./gateway.js";
19
+ import { applyGatewayOptions, gatewayLanguageModel, type GatewayRoutedDefaults } from "./gateway.js";
20
20
 
21
21
  /**
22
22
  * The effective `utility` route for a dream scope (channel key | silo name | other).
@@ -47,10 +47,12 @@ export function utilityRouteForScope(scope: string): ModelRoute {
47
47
  export function dreamCallSettings(
48
48
  route: ModelRoute,
49
49
  defaults: GatewayRoutedDefaults,
50
- ): { id: string; reasoning?: ModelRoute["reasoning"]; providerOptions?: Record<string, unknown> } {
50
+ ): { id: string; via?: string; reasoning?: ModelRoute["reasoning"]; providerOptions?: Record<string, unknown> } {
51
51
  const id = route.id ?? defaults.id;
52
52
  const options = applyGatewayOptions(route, {}, { id, defaultId: defaults.id }, defaults.gateway);
53
- return { id, reasoning: route.reasoning, providerOptions: options.providerOptions };
53
+ // The route's `via` wins over the role's (OPE-2556); absent both, the adapter uses the `gateway` target.
54
+ const via = route.via ?? defaults.via;
55
+ return { id, ...(via !== undefined ? { via } : {}), reasoning: route.reasoning, providerOptions: options.providerOptions };
54
56
  }
55
57
 
56
58
  /**
@@ -60,7 +62,7 @@ export function dreamCallSettings(
60
62
  export function resolvedDreamCallSettings(
61
63
  scope: string,
62
64
  defaults: GatewayRoutedDefaults,
63
- ): { id: string; reasoning?: ModelRoute["reasoning"]; providerOptions?: Record<string, unknown> } {
65
+ ): { id: string; via?: string; reasoning?: ModelRoute["reasoning"]; providerOptions?: Record<string, unknown> } {
64
66
  const s = dreamCallSettings(utilityRouteForScope(scope), defaults);
65
67
  stampModelCall(s.id);
66
68
  return s;
@@ -71,7 +73,7 @@ function gatewayDreamCompletion(defaults: GatewayRoutedDefaults): DreamCompletio
71
73
  return async (scope, prompt) => {
72
74
  const s = resolvedDreamCallSettings(scope, defaults);
73
75
  const { text } = await generateText({
74
- model: gateway(s.id),
76
+ model: gatewayLanguageModel(s.id, s.via),
75
77
  prompt,
76
78
  ...(s.reasoning !== undefined ? { reasoning: s.reasoning } : {}),
77
79
  ...(s.providerOptions !== undefined
@@ -12,6 +12,12 @@
12
12
  // ② REACHABILITY, from one GET of the gateway's PUBLIC model catalog — the same unauthenticated
13
13
  // endpoint `scripts/seed-model-catalog-cache.ts` reads.
14
14
  //
15
+ // ③ THE MODEL CONFIG (OPE-2556), when the app registered its models: one GET of the public
16
+ // `/v1/models/<id>/endpoints` read per model, judged against `models.targets` and `models.dataPolicy`
17
+ // (model-config-check.ts). A demand no provider can meet turns the row "no". The route is public, so the
18
+ // detail names no model, provider or target, and counts nothing. The answer is cached for ten minutes, so an
19
+ // anonymous caller cannot make the route fan out a read per request.
20
+ //
15
21
  // It does NOT prove the deployment's credential is ACCEPTED: the only call that proves that is an
16
22
  // inference call, which is neither cheap nor free, and a status route an unbounded caller can poke
17
23
  // must not be able to spend money. The detail string says so in as many words, because a status
@@ -20,7 +26,17 @@
20
26
  // FAIL-SOFT and BOUNDED: a 2-second abort, and any throw answers "no" with no vendor error text
21
27
  // forwarded (a fetch failure's message carries the host, and the status route is public).
22
28
 
23
- import { registerStatusProbe, onVercel, type ProbeAnswer } from "@open-everywhere/core/agent/adapter-api.js";
29
+ import {
30
+ DEFAULT_MODEL_VIA,
31
+ getSettings,
32
+ modelTargetFor,
33
+ registerStatusProbe,
34
+ onVercel,
35
+ type ProbeAnswer,
36
+ type ResolvableModel,
37
+ } from "@open-everywhere/core/agent/adapter-api.js";
38
+ import { checkModelConfig, type ConfigCheckResult } from "./model-config-check.js";
39
+ import { targetBaseUrl } from "./target-url.js";
24
40
 
25
41
  /** The gateway's PUBLIC, unauthenticated model catalog — eve's own `AI_GATEWAY_MODELS_CATALOG_URL`. */
26
42
  export const GATEWAY_CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models/catalog";
@@ -28,13 +44,19 @@ export const GATEWAY_CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models/catal
28
44
  /** How long the probe waits before it calls the gateway unreachable. */
29
45
  export const GATEWAY_PROBE_TIMEOUT_MS = 2_000;
30
46
 
47
+ /** The `gateway` target's base URL: `models.targets.gateway.url`, else the deprecated `OE_AI_GATEWAY_URL` in `env`. */
48
+ function gatewayBaseUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
49
+ const targets = getSettings().models.targets;
50
+ return targetBaseUrl(DEFAULT_MODEL_VIA, modelTargetFor(DEFAULT_MODEL_VIA, targets), env);
51
+ }
52
+
31
53
  /**
32
- * The catalog URL this deployment probes. `OE_AI_GATEWAY_URL` retargets the whole gateway (a relay,
33
- * an on-prem proxy, a fixture), and it names the CHAT base (`…/v4/ai`), so the catalog is taken
34
- * from its ORIGIN rather than appended to it.
54
+ * The catalog URL this deployment probes. The `gateway` target's url (or the deprecated `OE_AI_GATEWAY_URL`)
55
+ * retargets the whole gateway (a relay, an on-prem proxy, a fixture), and it names the CHAT base (`…/v4/ai`), so the
56
+ * catalog is taken from its ORIGIN rather than appended to it.
35
57
  */
36
58
  export function gatewayCatalogUrl(env: NodeJS.ProcessEnv = process.env): string {
37
- const override = env.OE_AI_GATEWAY_URL?.trim();
59
+ const override = gatewayBaseUrl(env);
38
60
  if (!override) return GATEWAY_CATALOG_URL;
39
61
  try {
40
62
  return `${new URL(override).origin}/v1/models/catalog`;
@@ -49,6 +71,65 @@ export interface GatewayProbeDeps {
49
71
  readonly env?: NodeJS.ProcessEnv;
50
72
  readonly fetchImpl?: typeof globalThis.fetch;
51
73
  readonly vercel?: boolean;
74
+ /** The models to run the config check on. Absent: the registered list, else no config check. */
75
+ readonly models?: () => readonly ResolvableModel[];
76
+ /** The config-check cache. Absent: the module's own. */
77
+ readonly cache?: ConfigCheckCache;
78
+ readonly now?: () => number;
79
+ }
80
+
81
+ /** How long one config-check answer is reused. */
82
+ const MODEL_CONFIG_CHECK_TTL_MS = 10 * 60_000;
83
+
84
+ /** A one-entry cache of the last config-check answer. */
85
+ export interface ConfigCheckCache {
86
+ value?: { readonly at: number; readonly result: ConfigCheckResult };
87
+ }
88
+ const CONFIG_CHECK_CACHE: ConfigCheckCache = {};
89
+
90
+ let registeredModels: (() => readonly ResolvableModel[]) | undefined;
91
+
92
+ /**
93
+ * Register the models the deployment can resolve, so the `model-route` probe also runs the live config check.
94
+ * Called once from the app's boot wiring, beside the other registrations.
95
+ */
96
+ export function registerModelConfigCheck(models: () => readonly ResolvableModel[]): void {
97
+ registeredModels = models;
98
+ }
99
+
100
+ /** The config-check answer in closed-vocabulary words, for the PUBLIC status route. Pure. */
101
+ function publicConfigWords(r: ConfigCheckResult): string {
102
+ if (r.met === "no") return "the model config has a models.dataPolicy, providers.only or target dataPolicy demand that no provider can serve for at least one model — run `npm run check:model-config` for the names";
103
+ // An unused `providers.only` name is a warning on a config that was fully measured: say so, not "not measured".
104
+ if (r.met === "unknown" && r.findings.every((f) => f.row.endsWith(".providers.only"))) {
105
+ return "the model config can be served; a providers.only name serves none of the checked models — run `npm run check:model-config` for the names";
106
+ }
107
+ if (r.met === "unknown") return "the model config was not fully measured — run `npm run check:model-config` for the details";
108
+ return "the model config can be served";
109
+ }
110
+
111
+ /** Run the config check, or reuse an answer younger than {@link MODEL_CONFIG_CHECK_TTL_MS}. Never throws. */
112
+ async function cachedConfigCheck(deps: GatewayProbeDeps, models: () => readonly ResolvableModel[]): Promise<ConfigCheckResult> {
113
+ const cache = deps.cache ?? CONFIG_CHECK_CACHE;
114
+ const now = (deps.now ?? Date.now)();
115
+ if (cache.value !== undefined && now - cache.value.at < MODEL_CONFIG_CHECK_TTL_MS) return cache.value.result;
116
+ const env = deps.env ?? process.env;
117
+ let result: ConfigCheckResult;
118
+ try {
119
+ const settings = getSettings().models;
120
+ result = await checkModelConfig({
121
+ models: models(),
122
+ targets: settings.targets,
123
+ policy: settings.dataPolicy,
124
+ baseUrlFor: (via) => targetBaseUrl(via, modelTargetFor(via, settings.targets), env),
125
+ timeoutMs: GATEWAY_PROBE_TIMEOUT_MS,
126
+ ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
127
+ });
128
+ } catch {
129
+ result = { met: "unknown", findings: [{ met: "unknown", row: "config", detail: "the model config could not be read" }], checked: 0 };
130
+ }
131
+ cache.value = { at: now, result };
132
+ return result;
52
133
  }
53
134
 
54
135
  /** Run the probe once. Exported so a test drives every arm with no network. */
@@ -61,10 +142,10 @@ export async function probeGatewayRoute(deps: GatewayProbeDeps = {}): Promise<Pr
61
142
  // owes nobody a model catalog and may need no key of ours. Both of this probe's facts are then
62
143
  // about the shipped Vercel gateway, not about the route the deployment actually uses — and
63
144
  // "measured, and it does not hold" would be false for a deployment whose models route fine.
64
- const retargeted = (env.OE_AI_GATEWAY_URL ?? "").trim().length > 0;
145
+ const retargeted = gatewayBaseUrl(env) !== undefined;
65
146
  if (!vercel && !keySet) {
66
147
  if (retargeted) {
67
- return { met: "unknown", detail: "the model route is retargeted by OE_AI_GATEWAY_URL and holds no AI_GATEWAY_API_KEY — a relay may need neither, so this was not measured" };
148
+ return { met: "unknown", detail: "the model route is retargeted (models.targets.gateway.url) and holds no AI_GATEWAY_API_KEY — a relay may need neither, so this was not measured" };
68
149
  }
69
150
  return { met: "no", detail: "no gateway credential: off Vercel the deployment needs AI_GATEWAY_API_KEY (on Vercel, OIDC authenticates it)" };
70
151
  }
@@ -83,10 +164,15 @@ export async function probeGatewayRoute(deps: GatewayProbeDeps = {}): Promise<Pr
83
164
  }
84
165
  return { met: "no", detail: `the model gateway answered HTTP ${res.status} to a catalog read; the credential posture is ${credential}` };
85
166
  }
86
- return {
87
- met: "yes",
88
- detail: `the model gateway answered a catalog read and the credential posture is ${credential} — this does not prove an inference call is authorised`,
89
- };
167
+ const reached = `the model gateway answered a catalog read and the credential posture is ${credential} — this does not prove an inference call is authorised`;
168
+ const models = deps.models ?? registeredModels;
169
+ if (models === undefined) return { met: "yes", detail: reached };
170
+ // ③ The config check. A demand no provider can meet makes the row "no": every call of that model would fail closed.
171
+ // An unknown part keeps "yes", because the route itself was measured, and the detail says what was not.
172
+ // 🔴 THE STATUS ROUTE IS PUBLIC, so the detail is closed-vocabulary prose: no model id, no provider slug, no target
173
+ // name and no count. `npm run check:model-config` names them, from the app.
174
+ const check = await cachedConfigCheck(deps, models);
175
+ return { met: check.met === "no" ? "no" : "yes", detail: `${reached}; ${publicConfigWords(check)}` };
90
176
  } catch {
91
177
  return { met: "no", detail: `the model gateway did not answer within ${GATEWAY_PROBE_TIMEOUT_MS} ms; the credential posture is ${credential}` };
92
178
  } finally {
@@ -1,7 +1,9 @@
1
- // Gateway transport for the image describer — core owns the seam. Zero-data-retention is enforced AT THE
2
- // GATEWAY by org policy; the call is fail-closed, reports its own spend, and must NOT stamp the model.
1
+ // Gateway transport for the image describer — core owns the seam. The model comes from gateway.ts, so the call
2
+ // carries `models.dataPolicy` (OPE-2535); a gateway's own org policy applies on top. The call is fail-closed,
3
+ // reports its own spend, and must NOT stamp the model.
3
4
 
4
- import { gateway, generateText } from "ai";
5
+ import { generateText } from "ai";
6
+ import { gatewayLanguageModel } from "./gateway.js";
5
7
  import {
6
8
  type ImageDescribeRequest,
7
9
  type ImageDescribeResult,
@@ -14,9 +16,11 @@ import {
14
16
  export interface GatewayVisionDefaults {
15
17
  /** Gateway vision model id, e.g. "alibaba/qwen3-vl-instruct". */
16
18
  readonly id: string;
19
+ /** The `models.targets` entry the call goes through (OPE-2556). Absent: `gateway`. */
20
+ readonly via?: string;
17
21
  /**
18
- * providerOptions.gateway for the call, forwarded verbatim. Absent in the reference config: ZDR is the
19
- * gateway's org policy now. A self-host wanting a per-call posture passes one here.
22
+ * providerOptions.gateway for the call, forwarded verbatim. Absent in the reference config. The deployment's
23
+ * `models.dataPolicy` rows ride the call whatever this holds, and win over it.
20
24
  */
21
25
  readonly gateway?: Record<string, unknown>;
22
26
  }
@@ -128,7 +132,7 @@ export function gatewayImageDescriber(defaults: GatewayVisionDefaults): ImageDes
128
132
  let res: { text: string } & VisionCallTelemetry;
129
133
  try {
130
134
  res = await generateText({
131
- model: gateway(defaults.id),
135
+ model: gatewayLanguageModel(defaults.id, defaults.via),
132
136
  messages: [
133
137
  {
134
138
  role: "user",