diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 50c3520b0df..47f5495d944 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -15,6 +15,13 @@ compatibility fallback. With it, the order is: discovered/configured limits (the Azure metadata, model-ID metadata, enabled Context cap as the window, then 128k. An enabled Context cap still limits the result. Built-in providers keep their existing behavior and ignore the flag. +`GET /api/providers` includes `inferContextWindow` only when configured, preserving both `true` and +`false`. To change it, `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to +clear the setting and restore the default-off behavior; other types return `400`. Omitting it keeps +the current setting. A same-name `POST /api/providers` also keeps an omitted setting, including +`false` and any change committed while the save was validating. A submitted boolean wins, including +`false`, even if the stored value is `true`. + ```json { "providers": { "my-relay": { "adapter": "anthropic", "baseUrl": "https://relay.example/v1", "inferContextWindow": true } } } ``` @@ -413,6 +420,10 @@ The destination is the adapter, the base URL (scheme and host compared without r `hideRawReasoning` is an operator display policy, so a save of the same provider name keeps its stored value whenever the request omits it, including after a destination move. A value sent in the request wins, including `false`. +The optional `inferContextWindow` setting also survives same-name saves that omit it, including +destination moves. Its read, update, and clear behavior is described under +[Context windows on custom providers](#context-windows-on-custom-providers). + `PATCH /api/providers?name=` changes only the fields it names and keeps every other stored field, whatever the destination. It accepts all eight settings; `null` clears one. It also accepts a boolean `hideRawReasoning` or `null` to clear it. For the two reasoning lists an empty array is stored as an explicit opt-out rather than removed. With `webSearchBridge` enabled, a search continuation stays bound to the API-key selection that diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 19c55968fa8..0f395f8c3ba 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -650,6 +650,12 @@ setting interacts with `claudeCode.enabled` across upgrades and downgrades. | `GET, PUT /api/provider-context-caps` | Read or update global, all-provider, or one-provider context caps | 400 invalid request; 404 unknown provider | | `GET /api/provider-presets` | Return GUI provider presets derived from the runtime registry | — | +Provider reads include the optional boolean `inferContextWindow` when configured. Provider PATCH +accepts only a boolean or `null` to clear this setting; omission preserves it. A same-name POST +also preserves an omitted setting, including the latest stored `false`; an explicit submitted +boolean wins. See [Context windows on custom providers](/reference/configuration/providers/#context-windows-on-custom-providers) +for its default-off, custom-provider-only behavior. + The provider context-cap response includes `caps` (active limits) and `values` (last selected values, retained while disabled). Enabling a provider without `value` restores its selection, or uses the global `contextCapValue` on first enable. This also applies to OpenAI: the switch diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..25400bfd97d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1155,7 +1155,7 @@ "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-upstream-websocket.test.ts": "server", - "management-provider-validation.test.ts": "server", + "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", diff --git a/src/server/management/provider-overwrite-carry.ts b/src/server/management/provider-overwrite-carry.ts index 4ce914f58db..b0473a4c155 100644 --- a/src/server/management/provider-overwrite-carry.ts +++ b/src/server/management/provider-overwrite-carry.ts @@ -214,6 +214,13 @@ export function applyProviderCompatPatchFields( else return { error: "preserveResponsesReasoningContent must be a boolean" }; touched = true; } + if (Object.hasOwn(rawBody, "inferContextWindow")) { + const value = rawBody.inferContextWindow; + if (value === null) delete next.inferContextWindow; + else if (typeof value === "boolean") next.inferContextWindow = value; + else return { error: "inferContextWindow must be a boolean or null" }; + touched = true; + } if (Object.hasOwn(rawBody, "reasoningWireFormat")) { const value = rawBody.reasoningWireFormat; if (value === null) { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index fd0c1e68df0..dd5cafb7bcc 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -922,6 +922,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | -| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the nine operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning` and `preserveResponsesReasoningContent` accept a boolean or `null` to clear them. Custom Responses provider forms expose the latter as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | +| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the nine operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. An omitted `inferContextWindow` likewise keeps the latest live same-name state after asynchronous validation, including `false` or a concurrent clear; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning`, `preserveResponsesReasoningContent` and `inferContextWindow` accept a boolean or `null` to clear them, rejecting other types. `GET /api/providers` exposes configured `inferContextWindow` as an optional boolean without substituting a default; its [custom-only, default-off inference policy](catalog-vendor-context.md) is unchanged. Custom Responses provider forms expose `preserveResponsesReasoningContent` as a default-off plaintext replay switch; unrelated settings saves preserve omission and curated/OpenAI destinations keep their existing defaults. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..cc044c1ebcc 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -775,7 +775,7 @@ "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", "management-provider-reasoning-lists.test.ts": "server", "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", "management-provider-upstream-websocket.test.ts": "server", - "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", + "management-provider-context-inference.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", "protocol-routes.test.ts": "server", "protocol-settings-route.test.ts": "server", "protocol-provider-summary.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", diff --git a/tests/server/management-provider-context-inference.test.ts b/tests/server/management-provider-context-inference.test.ts new file mode 100644 index 00000000000..f4ae26c698d --- /dev/null +++ b/tests/server/management-provider-context-inference.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, validateConfigCandidate } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { clearModelCache, getStaleCached, setCached } from "../../src/codex/model-cache"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import * as stateStores from "../../src/lib/state-store-registrations"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { providerEditorConfigDTO, safeConfigDTO } from "../../src/server/auth-cors"; +import { handleProviderRoutes } from "../../src/server/management/provider-routes"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let home: string; +let previousOcxHome: string | undefined; +let previousCodexHome: string | undefined; +let destination: ReturnType>; +let reconcile: ReturnType>; + +beforeEach(() => { + previousOcxHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-provider-inference-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = join(home, "codex"); + mkdirSync(process.env.CODEX_HOME); + // Direct handler calls only: no listener, DNS, provider discovery, or client convergence. + destination = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + reconcile = spyOn(stateStores, "reconcileLiveStateStores").mockReturnValue({ storesVisited: 0, rowsRemoved: 0 }); + const acl = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => acl); + setAsyncIcaclsRunnerForTests(async () => acl); +}); + +afterEach(async () => { + try { + await flushConfigDirHardeningForTests(); + clearModelCache(); + } finally { + destination.mockRestore(); + reconcile.mockRestore(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } +}); + +function harness(inferContextWindow?: boolean) { + const config: OcxConfig = { + port: 10100, defaultProvider: "relay", openaiProviderTierVersion: 2, + providers: { relay: { + adapter: "openai-chat", baseUrl: "https://relay.example.test/v1", liveModels: false, + models: ["claude-opus-5-5"], + ...(inferContextWindow === undefined ? {} : { inferContextWindow }), + } }, + }; + const events: string[] = []; + const path = join(home, "config.json"); + // The injectable writer owns exactly this fixture file; reload uses the real config schema. + const persist = (saved: OcxConfig) => writeFileSync(path, JSON.stringify(saved)); + persist(config); + async function request(method: "GET" | "PATCH" | "POST", body?: unknown) { + const url = new URL("http://127.0.0.1/api/providers?name=relay"); + const response = await handleProviderRoutes({ + req: new Request(url, { method, headers: { "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), + url, config, version: "test", trustedLoopbackIngress: true, guiSessionIssuance: null, + deps: { saveConfigPreservingClaudeCode: saved => { events.push("save"); persist(saved); } }, + convergeCodexCatalog: async () => { + events.push("catalog"); + return { status: "committed", changed: false, degraded: false, notices: [] }; + }, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + if (!response) throw new Error("provider route did not handle fixture request"); + return response; + } + return { + config, events, path, request, + post: (fields: Record = {}) => request("POST", { name: "relay", provider: { + adapter: "openai-chat", baseUrl: "https://relay.example.test/v1", liveModels: false, + models: ["claude-opus-5-5"], ...fields, + } }), + row: async () => ((await (await request("GET")).json()) as Array) + .find(provider => provider.name === "relay")!, + }; +} + +describe("provider context inference management", () => { + test("PATCH true, false and null survive GET/reload and invalidate the catalog", async () => { + const h = harness(); + expect(await h.row()).not.toHaveProperty("inferContextWindow"); + for (const value of [true, false, null]) { + setCached("relay", []); + expect((await h.request("PATCH", { inferContextWindow: value })).status).toBe(200); + expect(h.config.providers.relay!.inferContextWindow).toBe(value ?? undefined); + expect((await h.row()).inferContextWindow).toBe(value ?? undefined); + expect(loadConfig().providers.relay!.inferContextWindow).toBe(value ?? undefined); + expect(getStaleCached("relay")).toBeNull(); + } + expect(await h.row()).not.toHaveProperty("inferContextWindow"); + expect(h.events).toEqual(["save", "catalog", "save", "catalog", "save", "catalog"]); + }); + + test("PATCH rejects non-booleans without applying a valid sibling field", async () => { + const h = harness(true); + const before = readFileSync(h.path, "utf8"); + for (const value of [0, 1, "true", "false", {}, []]) { + const response = await h.request("PATCH", { inferContextWindow: value, note: "must not save" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "inferContextWindow must be a boolean or null" }); + expect(h.config.providers.relay!.inferContextWindow).toBe(true); + expect(h.config.providers.relay!.note).toBeUndefined(); + expect(readFileSync(h.path, "utf8")).toBe(before); + } + expect(h.events).toEqual([]); + }); + + test.each([true, false])("POST omission retains stored %s; explicit false wins", async value => { + const h = harness(value); + expect((await h.post({ defaultModel: "claude-opus-5-5" })).status).toBe(200); + expect(h.config.providers.relay!.inferContextWindow).toBe(value); + expect(loadConfig().providers.relay!.inferContextWindow).toBe(value); + expect((await h.row()).inferContextWindow).toBe(value); + expect((await h.post({ inferContextWindow: false })).status).toBe(200); + expect(loadConfig().providers.relay!.inferContextWindow).toBe(false); + }); + + test("explicit POST values round-trip while invalid values and POST null fail schema validation", async () => { + const h = harness(); + for (const value of [true, false]) { + expect((await h.post({ inferContextWindow: value })).status).toBe(200); + expect((await h.row()).inferContextWindow).toBe(value); + expect(loadConfig().providers.relay!.inferContextWindow).toBe(value); + expect(providerEditorConfigDTO(h.config).providers.relay!.inferContextWindow).toBe(value); + expect(safeConfigDTO(h.config)).toHaveProperty("providers.relay.inferContextWindow", value); + } + const before = readFileSync(h.path, "utf8"); + for (const value of [null, 1, "true", {}, []]) { + expect((await h.post({ inferContextWindow: value })).status).toBe(400); + expect(readFileSync(h.path, "utf8")).toBe(before); + expect(h.config.providers.relay!.inferContextWindow).toBe(false); + expect(validateConfigCandidate({ ...h.config, providers: { relay: { + ...h.config.providers.relay, inferContextWindow: value, + } } }).ok).toBe(false); + } + }); + + for (const value of [true, false, null]) { + test(`POST omission keeps a concurrent PATCH ${value} after destination validation`, async () => { + const h = harness(value === true ? false : true); + let enter!: () => void; + let release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + destination.mockImplementation(async (_name, provider) => { + if (provider.note === "delayed overwrite") { enter(); await gate; } + return null; + }); + const pending = h.post({ note: "delayed overwrite" }); + try { + await Promise.race([entered, pending.then(() => { throw new Error("POST did not await destination validation"); })]); + expect((await h.request("PATCH", { inferContextWindow: value })).status).toBe(200); + } finally { + release(); + await pending; + } + expect((await pending).status).toBe(200); + expect(h.config.providers.relay!.inferContextWindow).toBe(value ?? undefined); + expect(loadConfig().providers.relay!.inferContextWindow).toBe(value ?? undefined); + }); + } +});