diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 8a294b7563..06a49bbc57 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -316,7 +316,9 @@ Combo's `decisionProvider`: } ``` -- The row's `baseUrl` must be the full decision endpoint and its path must end in `/systemone`. +- The row's `baseUrl` must be the full decision endpoint. HTTPS services may use any path, + such as `/v1/decisions`; plain HTTP endpoints must still end in `/systemone`. + Userinfo, query strings, and fragments are not accepted in decision URLs. The decision model is `defaultModel`, else the first `models` entry; a row with neither is treated as unusable and fails open without a request (`jev-latest` is TypeSafe's model and is never sent to a self-hosted host). The row is a decision service only: it is never published as a @@ -344,9 +346,31 @@ Combo's `decisionProvider`: The provider's **Test connection** sends a bounded probe decision to its endpoint. **Create JEV Auto** on a System One provider (keyless rows included) prefills that row as the -decision provider. Disabled rows, endpoints not ending in `/systemone`, and rows without a model +decision provider. Disabled rows, invalid decision URLs, and rows without a model are shown with a reason and cannot be picked. +For an alternative hosted service with the same decision request/response contract, add a separate +provider row and select it with the Combo's `decisionProvider`: + +```json +{ + "providers": { + "alternative-decider": { + "adapter": "jev-decision", + "baseUrl": "https://decisions.example/v1/decisions", + "apiKey": "${ALTERNATIVE_DECISION_KEY}", + "defaultModel": "decision-model-v1", + "liveModels": false + } + } +} +``` + +Set `decisionProvider: "alternative-decider"` on the existing JEV Combo. Its own credential and +model accompany the bounded decision state; TypeSafe environment credentials are never inherited. +The `jev` id remains canonical. HTTPS/TLS, outbound destination restrictions, redirect refusal, +request/response bounds, timeout, cancellation, and the eligible-choice allowlist still apply. + [Laya MLX](https://github.com/mizorewww/laya-mlx) runs typed decisions on Apple Silicon. To use it with this method, expose it through a System One-compatible HTTP wrapper, then configure a row like `ollama-tev1` above with the wrapper's full `/systemone` URL and accepted model id as `defaultModel`. @@ -794,7 +818,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | | `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. | | `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. | -| `decisionProvider` | No | `"jev"` | JEV only. Provider id of the decision service: `"jev"` (TypeSafe, valid without a provider row; the same as omission) or a configured `adapter: "jev-decision"` row with a `/systemone` `baseUrl`, such as a self-hosted Ollama `tev1`. | +| `decisionProvider` | No | `"jev"` | JEV only. Provider id of the decision service: `"jev"` (TypeSafe, valid without a provider row; the same as omission) or a configured `adapter: "jev-decision"` row with a full HTTPS decision URL or a local HTTP `/systemone` URL, such as a self-hosted Ollama `tev1`. | | `decisionTimeoutMs` | No | `4000` | JEV only. Integer from 1000 to 120000: the decision deadline before failing open to the first eligible target. | ## Troubleshooting diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 1b7c6d1de8..e0d0535b85 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -151,7 +151,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. | | `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. | | `displayName?` | `string` | — | Display-only catalog label, required and non-empty for a native alias. | -| `decisionProvider?` | `string` | `"jev"` | `strategy: "jev"` only. `"jev"` (the same as omitting it, and stored as omission) is the TypeSafe decision service, valid without a provider row; any other value must name a configured provider with `adapter: "jev-decision"` whose `baseUrl` ends in `/systemone`. | +| `decisionProvider?` | `string` | `"jev"` | `strategy: "jev"` only. `"jev"` (the same as omitting it, and stored as omission) is the TypeSafe decision service, valid without a provider row; any other value must name a configured provider with `adapter: "jev-decision"` and a full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` endpoint. Userinfo, query strings, and fragments are refused. | | `decisionModel?` | `string` | unset | `strategy: "jev"` only, mutually exclusive with `decisionProvider`. An ordinary opencodex route (for example `ollama/qwen3:4b`) asked to pick one offered option as JSON. It runs with the selected provider's stored credentials, never the caller's, and cannot resolve to this combo, any JEV combo, or a `jev-decision` row. | | `decisionTimeoutMs?` | `number` | `4000` | `strategy: "jev"` only. Decision deadline before failing open, 1000–120000 ms. | diff --git a/src/combos/jev-decision-contract.ts b/src/combos/jev-decision-contract.ts index d7423a7cd2..33e3aa59ee 100644 --- a/src/combos/jev-decision-contract.ts +++ b/src/combos/jev-decision-contract.ts @@ -10,11 +10,34 @@ export const JEV_DECISION_TIMEOUT_MAX_MS = 120_000; /** Decision deadline when a combo sets no `decisionTimeoutMs`. */ export const JEV_DECISION_TIMEOUT_DEFAULT_MS = 4_000; -/** Whether a self-hosted decision endpoint follows the documented Jev `/systemone` path. */ +/** HTTPS decision services may use any path; local cleartext keeps the `/systemone` contract. */ export function isSystemOneEndpoint(baseUrl: string): boolean { try { - return new URL(baseUrl.trim()).pathname.replace(/\/+$/, "").endsWith("/systemone"); + const raw = baseUrl.trim(); + // URL drops empty delimiters ("?", "#", "@") and strips tab/CR/LF, so check the raw text first. + if (/[\u0000-\u001f\u007f]/.test(raw)) return false; + const authority = raw.replace(/^[a-z][a-z\d+.-]*:[/\\]*/i, "").split(/[/\\]/, 1)[0] ?? ""; + if (/[?#]/.test(raw) || authority.includes("@")) return false; + const url = new URL(raw); + if (url.username || url.password || url.search || url.hash) return false; + // URL canonicalizes address literals; keep this import-free for the dashboard bundle. + const host = url.hostname; + const local = host === "localhost" || host === "[::1]" + || /^(?:(?:127|10)\.\d+|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d+\.\d+$/.test(host) + || /^\[f[cd][\da-f]{2}:/.test(host) || /^\[::ffff:7f[\da-f]{2}:/.test(host); + return url.protocol === "https:" + || url.protocol === "http:" && local && url.pathname.replace(/\/+$/, "").endsWith("/systemone"); } catch { return false; } } + +/** + * The URL a decision row is sent to. A `/systemone` path keeps its historical trailing-slash + * normalization; any other HTTPS path is the operator's exact endpoint. + */ +export function jevDecisionEndpointUrl(baseUrl: string): string { + const raw = baseUrl.trim(); + const stripped = raw.replace(/\/+$/, ""); + return stripped.endsWith("/systemone") ? stripped : raw; +} diff --git a/src/combos/jev.ts b/src/combos/jev.ts index d04e9cd0cd..1d330d870c 100644 --- a/src/combos/jev.ts +++ b/src/combos/jev.ts @@ -10,6 +10,7 @@ import { } from "../providers/api-key-resolve"; import { providerMatchesRegistryTransport } from "../providers/registry"; import type { OcxComboDefaultEffort, OcxConfig, OcxProviderConfig } from "../types"; +import { jevDecisionEndpointUrl } from "./jev-decision-contract"; import { isSystemOneEndpoint, JEV_DECISION_TIMEOUT_DEFAULT_MS, @@ -674,7 +675,7 @@ function selfHostedApiKey(name: string, apiKey: string | undefined): string | un * only while the row still matches the registry transport, and the environment fallbacks exist * only for that URL. A retargeted `jev` row therefore keeps today's behavior instead of becoming a * custom destination. Any other id must be an enabled `jev-decision` row whose baseUrl is a - * `/systemone` endpoint and which names its own model; only its own key may accompany it, so no + * full HTTPS decision endpoint (or a local HTTP `/systemone` endpoint) and names its own model; only its own key may accompany it, so no * TypeSafe credential can reach a self-hosted service. `undefined` means no usable decision * service (reported through the existing `missing_key` gate); `null` means the request's * destination scope refused it before any credential access. @@ -707,7 +708,7 @@ function jevDecisionEndpoint( }; } if (configured?.adapter !== "jev-decision" || typeof configured.baseUrl !== "string") return undefined; - const url = configured.baseUrl.trim().replace(/\/+$/, ""); + const url = jevDecisionEndpointUrl(configured.baseUrl); if (!url || !isSystemOneEndpoint(url)) return undefined; // `jev-latest` is TypeSafe's model name; a self-hosted host must name its own. const model = configured.defaultModel?.trim() || configured.models?.[0]?.trim(); diff --git a/src/combos/types.ts b/src/combos/types.ts index 7b8912c9e4..6353db3b7a 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -302,7 +302,7 @@ export function comboConfigIssues( } else if (!isSystemOneEndpoint(String(providers[decisionProvider]?.baseUrl ?? ""))) { issues.push({ path: ["decisionProvider"], - message: `decisionProvider "${decisionProvider}" baseUrl must be the full decision endpoint ending in /systemone`, + message: `decisionProvider "${decisionProvider}" baseUrl must be a full HTTPS decision endpoint or an HTTP /systemone endpoint`, }); } else if (options.requireUsableDecisionService && providers[decisionProvider]?.disabled === true) { issues.push({ diff --git a/src/server/management/decision-routes.ts b/src/server/management/decision-routes.ts index a08286809d..cf448c25b1 100644 --- a/src/server/management/decision-routes.ts +++ b/src/server/management/decision-routes.ts @@ -1,4 +1,5 @@ import { jsonResponse } from "../auth-cors"; +import { jevDecisionEndpointUrl } from "../../combos/jev-decision-contract"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { isPlainRecord } from "./shared"; @@ -29,7 +30,7 @@ function decisionServiceIssue( provider: DecisionRow, isSystemOneEndpoint: (url: string) => boolean, ): { url: string; model: string; issue?: "disabled" | "endpoint" | "model" } { - const url = typeof provider.baseUrl === "string" ? provider.baseUrl.trim().replace(/\/+$/, "") : ""; + const url = typeof provider.baseUrl === "string" ? jevDecisionEndpointUrl(provider.baseUrl) : ""; const model = provider.defaultModel?.trim() || provider.models?.[0]?.trim() || ""; const issue = provider.disabled === true ? "disabled" diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 6fe108beb5..89c045b4ad 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -319,7 +319,7 @@ and the upstream URL through `handleResponses`. ## TypeSafe JEV decision provider -The JEV Combo decision contract (TypeSafe, self-hosted System One rows, and opencodex-model +The JEV Combo decision contract (TypeSafe, compatible HTTPS services with any endpoint path, local System One rows, and opencodex-model decision backends) lives in [JEV Decision Routing](providers/jev-decision.md). ## Preset notes diff --git a/structure/providers/jev-decision.md b/structure/providers/jev-decision.md index 1913a988cf..d7470af28a 100644 --- a/structure/providers/jev-decision.md +++ b/structure/providers/jev-decision.md @@ -21,8 +21,8 @@ canonical registry transport, with `TYPESAFE_API_KEY` and the standard provider- either credential through the JEV client; a retargeted `jev` row is ignored, never a custom destination. Automated coverage mocks TypeSafe; live-key behavior is an operator smoke boundary. A Combo's `decisionProvider` selects the service: omitted or `"jev"` (stored as omission) is that canonical path with `jev-latest`; any other id must be an enabled `jev-decision` row with a full -`/systemone` `baseUrl` and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe -env reference or foreign keychain entry makes it unusable). `allowLocalCleartextPost` in +full HTTPS decision `baseUrl` (any path) or a local HTTP `/systemone` URL and a `defaultModel`/`models[0]`, sending only its own `apiKey` (a TypeSafe +env reference or foreign keychain entry makes it unusable). The row's `authMode` attaches no other credential: only its own `apiKey` is sent, as a Bearer header. URLs with userinfo, query strings, or fragments (including an empty `?`, `#`, or `@`) are refused before a send; an HTTPS path is sent exactly as configured, while a `/systemone` path keeps its trailing-slash normalization. Shared validation rejects public HTTP hosts; the local-literal check is tested against the transport allowlist. `allowLocalCleartextPost` in `src/lib/provider-outbound.ts` admits `http:` only with the row's explicit `allowPrivateNetwork`, a `localhost`/loopback/RFC 1918/ULA host whose answers stay in that set, and no proxy. Options go out as strings (Ollama requires them); under 2 or over 26 fail open locally (`no_choices`/`invalid`), unusable diff --git a/tests/gui/combo-workspace-jev-decision.test.ts b/tests/gui/combo-workspace-jev-decision.test.ts index 40b824c72d..396e6756ca 100644 --- a/tests/gui/combo-workspace-jev-decision.test.ts +++ b/tests/gui/combo-workspace-jev-decision.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { localCleartextAddressAllowed } from "../../src/lib/provider-outbound"; import { JEV_DECISION_TIMEOUT_DEFAULT_MS as SERVER_TIMEOUT_DEFAULT_MS, JEV_DECISION_TIMEOUT_MAX_MS as SERVER_TIMEOUT_MAX_MS, @@ -160,10 +161,26 @@ describe("JEV decision service in the combo workspace", () => { }); test("the GUI endpoint check is the server's", () => { - for (const url of ["http://h/v1/systemone", "http://h/v1/systemone/", "http://h/v1", "not a url", ""]) { + for (const url of ["https://decisions.example/v1/decisions", "http://localhost/v1/systemone", "http://127.0.0.1/v1/systemone/", "http://h/v1", "not a url", ""]) { expect(jevDecisionRowIssue({ adapter: "jev-decision", baseUrl: url, defaultModel: "m" }) === null) .toBe(isSystemOneEndpoint(url)); } + for (const url of ["http://decisions.example/v1/systemone", "http://10.example.com/v1/systemone", "ftp://decisions.example/v1/systemone", "https://user:pass@example.test/v1/decisions", "https://decisions.example/v1/decisions?key=secret", "https://decisions.example/v1/decisions#fragment", + "https://decisions.example/v1/decisions?", "https://decisions.example/v1/decisions#", "https://decisions.example/v1/decisions?#", + "https://@decisions.example/v1/decisions", "https://:@decisions.example/v1/decisions", + "https:\t//@decisions.example/v1/decisions", "https:\n//:@decisions.example/v1/decisions", "https://decisions.example/v1/de\rcisions"]) { + expect(isSystemOneEndpoint(url)).toBeFalse(); + } + expect(isSystemOneEndpoint("https://decisions.example/v1/user@decisions")).toBeTrue(); + }); + + test("HTTP endpoint literals match the transport's local address allowlist", () => { + for (const address of ["127.0.0.1", "127.255.255.254", "10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.1.1", + "::1", "::ffff:127.0.0.1", "fc00::1", "fdff::1", "172.15.0.1", "172.32.0.1", "192.169.1.1", "8.8.8.8", + "0.0.0.0", "100.64.0.1", "169.254.169.254", "198.18.0.1", "::", "fe80::1", "::ffff:10.0.0.1", "64:ff9b::1"]) { + const host = address.includes(":") ? `[${address}]` : address; + expect(isSystemOneEndpoint(`http://${host}/v1/systemone`)).toBe(localCleartextAddressAllowed(address)); + } }); test("the read-only summary names the service, its endpoint and timeout", () => { diff --git a/tests/routing/jev-decision-destination.test.ts b/tests/routing/jev-decision-destination.test.ts index 77cfb01610..0f91984deb 100644 --- a/tests/routing/jev-decision-destination.test.ts +++ b/tests/routing/jev-decision-destination.test.ts @@ -10,7 +10,7 @@ import { import type { OcxConfig, OcxProviderConfig } from "../../src/types"; type JevPost = NonNullable; -const CUSTOM_URL = "https://decider.example/v1/systemone"; +const CUSTOM_URL = "https://decider.example/v1/decisions"; const ENV_SECRETS = { TYPESAFE_API_KEY: "typesafe-environment-secret", JEV_API_KEY: "jev-environment-secret", @@ -86,6 +86,65 @@ function expectNoTypeSafeSecrets(init: Parameters[3]) { } describe("JEV decision destination credential ownership", () => { + test("an incompatible selected adapter never sends or falls back to TypeSafe", async () => { + const row: OcxProviderConfig = { ...customRow, adapter: "openai-chat" }; + const { calls, post } = recordingPost(); + expect(await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", row), decisionProvider: "custom-decider", post, + })).toMatchObject({ ...fallback, gate: "missing_key" }); + expect(calls).toHaveLength(0); + }); + + test.each(["oauth", "local", "forward"] as const)("authMode %s sends only the row's own key to its own endpoint", async (authMode) => { + const { calls, post } = recordingPost(); + expect((await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", { ...customRow, authMode }), decisionProvider: "custom-decider", post, + })).gate).toBe("apply"); + expect(calls.map(call => call.url)).toEqual([CUSTOM_URL]); + expect(new Headers(calls[0]!.init.headers).get("authorization")).toBe("Bearer custom-secret"); + expectNoTypeSafeSecrets(calls[0]!.init); + }); + + test.each([ + [`${CUSTOM_URL}/`, `${CUSTOM_URL}/`], + ["https://decider.example/v1/systemone/", "https://decider.example/v1/systemone"], + ])("baseUrl %s is sent to %s", async (baseUrl, expected) => { + const { calls, post } = recordingPost(); + await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", { ...customRow, baseUrl }), decisionProvider: "custom-decider", post, + }); + expect(calls.map(call => call.url)).toEqual([expected]); + }); + + test.each(["https:\t//@decider.example/v1/decisions", "https://decider.example/v1/decisions?", "https://:@decider.example/v1/decisions"])( + "baseUrl %j with a stripped delimiter never sends", + async (baseUrl) => { + const { calls, post } = recordingPost(); + expect(await resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", { ...customRow, baseUrl }), decisionProvider: "custom-decider", post, + })).toMatchObject({ ...fallback, gate: "missing_key" }); + expect(calls).toHaveLength(0); + }, + ); + + test("a custom HTTPS path preserves caller cancellation by identity", async () => { + const controller = new AbortController(); + const reason = new DOMException("caller stopped", "AbortError"); + const post: JevPost = async (_name, _provider, url, init) => { + expect(url).toBe(CUSTOM_URL); + controller.abort(reason); + throw init.signal?.reason; + }; + await expect(resolveJevDecision({ + body: { input: "Choose a target." }, candidates, fallback, + config: configWith("custom-decider", customRow), decisionProvider: "custom-decider", post, signal: controller.signal, + })).rejects.toBe(reason); + }); + for (const apiKey of ["custom-secret", undefined]) { test(`self-hosted row with ${apiKey ? "its own key" : "no key"} never borrows TypeSafe environment secrets`, async () => { const { calls, post } = recordingPost(); diff --git a/tests/routing/jev-decision-provider-combo.test.ts b/tests/routing/jev-decision-provider-combo.test.ts index 8393e46add..a71470ff5d 100644 --- a/tests/routing/jev-decision-provider-combo.test.ts +++ b/tests/routing/jev-decision-provider-combo.test.ts @@ -71,6 +71,9 @@ describe("JEV decisionProvider combo validation", () => { expect(issuesFor({ strategy: "jev", decisionProvider: "jev" })).toEqual([]); expect(issuesFor({ strategy: "jev" })).toEqual([]); expect(issuesFor({ strategy: "jev", decisionProvider: null, decisionTimeoutMs: null })).toEqual([]); + const rows = providers(); + rows.remote = { ...selfHostedRow, baseUrl: "https://decisions.example/v1/decisions", apiKey: "remote-secret" }; + expect(issuesFor({ strategy: "jev", decisionProvider: "remote" }, rows)).toEqual([]); }); test("rejects unknown rows, non-decision adapters, and use outside the jev strategy", () => { @@ -87,7 +90,7 @@ describe("JEV decisionProvider combo validation", () => { expect(issuesFor({ strategy: "jev" }, rows)).toEqual([]); rows.local = { adapter: "jev-decision", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true }; expect(issuesFor({ strategy: "jev", decisionProvider: "local" }, rows)).toEqual([ - { path: ["decisionProvider"], message: 'decisionProvider "local" baseUrl must be the full decision endpoint ending in /systemone' }, + { path: ["decisionProvider"], message: 'decisionProvider "local" baseUrl must be a full HTTPS decision endpoint or an HTTP /systemone endpoint' }, ]); expect(issuesFor({ strategy: "failover", decisionProvider: "ollama-tev1" }, rows)).toEqual([ { path: ["decisionProvider"], message: 'decisionProvider is only valid with strategy "jev"' }, @@ -291,7 +294,7 @@ describe("JEV decisionProvider management round-trip", () => { }); expect(endpoint.status).toBe(400); expect((await endpoint.json() as { error: string }).error).toContain( - 'combos.custom.decisionProvider: decisionProvider "ollama-tev1" baseUrl must be the full decision endpoint', + 'combos.custom.decisionProvider: decisionProvider "ollama-tev1" baseUrl must be a full HTTPS decision endpoint', ); expect(cfg.providers["ollama-tev1"]).toMatchObject({ adapter: "jev-decision", baseUrl: selfHostedRow.baseUrl }); expect(readFileSync(getConfigPath(), "utf8")).toBe(before);