From 5e352b73f42c7afea35d52c319e09f4502fb05b7 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 08:32:57 +0000 Subject: [PATCH 01/13] docs: define harness model configuration preparation contract --- CONTRIBUTING.md | 9 ++++++++ apps/parsar-daemon/internal/agent/harness.go | 23 ++++++++++++++++++++ contracts/agents-api/harness-onboarding.md | 19 ++++++++++++++++ internal/agentdaemon/proto/harness_config.go | 12 ++++++++++ 4 files changed, 63 insertions(+) create mode 100644 internal/agentdaemon/proto/harness_config.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c991a446..294ae50c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3588,6 +3588,15 @@ The [package artifact contract](packages/claude-sdk-adapter/README.md#runtime-ar owns build isolation, dependency export, readiness and relocation checks. Keep the adapter artifact independent of the Core binary and product sources. +### Harness model configuration + +The configuration preparation contract in `apps/parsar-daemon/internal/agent/harness.go` +is mandatory for every Executor factory. Shared wire types live in +`internal/agentdaemon/proto`; native model fields and their application are documented +in [Harness onboarding](contracts/agents-api/harness-onboarding.md#native-model-configuration). +Public configuration sources and inheritance belong to +[model execution](contracts/agents-api/model-execution.md). + ### Harness selection and Agent defaults Core accepts the optional `agent.x_agents_core.harness` extension through the diff --git a/apps/parsar-daemon/internal/agent/harness.go b/apps/parsar-daemon/internal/agent/harness.go index 664115aba..145f578e7 100644 --- a/apps/parsar-daemon/internal/agent/harness.go +++ b/apps/parsar-daemon/internal/agent/harness.go @@ -29,6 +29,29 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) +// ExecutorFactory must prepare model configuration before native startup and +// before submitting model input; accepted fields must never be silently dropped. +// proto.HarnessConfig is a JSON object: omission and {} are empty configuration; +// explicit null, arrays and objects above 16 KiB of serialized UTF-8 bytes are +// invalid. The selected +// adapter owns field names, meanings and native validation. See the native model +// configuration section of contracts/agents-api/harness-onboarding.md. +// Core freezes model, model_provider and proto.HarnessConfig in the Session. +// Runtime resolves modeltransport first; adapters consume only its final effective +// connection (address, protocol and authentication). +// Each adapter declares safe native model fields in internal/harnessconfig and +// validates the same declaration at admission and again before native application. +// Unknown fields and conflicts with model, provider/authentication, workspace, +// tools/MCP, permissions or lifecycle controls fail with a value-free error. +// Apply accepted fields directly to native config or SDK options, without a +// cross-Harness parameter translation layer. Never merge arbitrary host config. +// Preparation failure cleans up owned configuration files, processes/SDK resources +// and proxies. Unconfirmed cleanup returns a non-nil Executor with the error under +// the factory ownership contract. The logical configuration snapshot remains fixed +// across Turns and reconnects; rebuilding an Executor may regenerate local proxy +// addresses or short-lived credentials. Configuration defaults and inheritance +// belong to the public API contract. + // Required execution lifecycle. // ExecutorFactory prepares without model input. A failed factory retains any diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index e4a41939a..50c477366 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -52,6 +52,25 @@ see [capability preparation](environments.md#runtime-capability-preparation). Model providers supply model communication configuration, not Turn scheduling or native process ownership. +## Native model configuration + +[`agent/harness.go`](../../apps/parsar-daemon/internal/agent/harness.go) owns the +preparation, validation, reserved-field, effective-connection and cleanup contract. +The shared wire object is `proto.HarnessConfig`. Adapter declarations in +`internal/harnessconfig/` own these native fields: + +| Harness | Accepted native fields | Application | +| --- | --- | --- | +| Codex | `model_reasoning_effort`: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` | Native app-server `-c model_reasoning_effort=...` | +| Claude SDK | `effort`: `low`, `medium`, `high`, `xhigh`, `max`; `thinking`: the SDK's `adaptive`, `enabled` or `disabled` object | SDK `Options.effort` and `Options.thinking` | +| MiniMax Code | Empty object only | Existing provider token limits remain required; additional native generation parameters are not qualified | + +Claude `thinking` accepts `display` (`summarized` or `omitted`) for adaptive or +enabled thinking, and an optional positive integer `budgetTokens` only for +`enabled`. The deprecated `maxThinkingTokens` alternative is rejected. +These are native settings, not a shared reasoning vocabulary; model availability +and provider support remain the selected harness's responsibility. + ## Required adapter interfaces [`agent/harness.go`](../../apps/parsar-daemon/internal/agent/harness.go) is the diff --git a/internal/agentdaemon/proto/harness_config.go b/internal/agentdaemon/proto/harness_config.go new file mode 100644 index 000000000..4e85b6d7b --- /dev/null +++ b/internal/agentdaemon/proto/harness_config.go @@ -0,0 +1,12 @@ +package proto + +// HarnessConfig is the shared JSON-object wire shape for native model parameters. +// The selected Harness owns its field semantics; no cross-Harness translation is +// implied. Omission and {} mean empty configuration; null, arrays and encoded +// objects larger than 16 KiB of serialized UTF-8 bytes are invalid. Unknown and reserved fields must fail +// before model input, without echoing submitted keys or values. Connection, +// authentication, execution policy and lifecycle are not configurable here. +// Runtime applies a copied Session snapshot only after modeltransport has prepared +// the final provider connection. The Executor retains the logical configuration +// across Turns; local proxy addresses and credentials may be regenerated on rebuild. +type HarnessConfig = map[string]any From 75f03413cf741c0b2d511908a40a0887eeffccff Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 08:34:06 +0000 Subject: [PATCH 02/13] Unify model configuration client types and projections --- .../agents-client/saved-agent-defaults.md | 39 +++++++++++++++---- .../agents-client/src/admin-client.test.ts | 26 ++++++------- packages/agents-client/src/admin-client.ts | 35 ++++++++++------- .../agents-client/src/admin-projection.ts | 19 ++++----- packages/agents-client/src/client.test.ts | 6 +-- packages/agents-client/src/client.ts | 19 ++++++--- .../src/execution-configuration-projection.ts | 6 ++- .../src/execution-configuration.test.ts | 4 ++ .../agents-client/src/protocol-types.test.ts | 7 ++++ .../src/provider-observations.test.ts | 10 ++--- .../src/saved-agent-defaults.test.ts | 28 ++++++++++++- packages/agents-client/src/types.ts | 33 ++++++++++++---- 12 files changed, 163 insertions(+), 69 deletions(-) diff --git a/packages/agents-client/saved-agent-defaults.md b/packages/agents-client/saved-agent-defaults.md index 5f8432594..b5f5b819b 100644 --- a/packages/agents-client/saved-agent-defaults.md +++ b/packages/agents-client/saved-agent-defaults.md @@ -11,6 +11,7 @@ const agent = await client.createAgent({ model: "requested-model", x_agents_core: { harness: "codex", + harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: modelBaseURL, @@ -37,25 +38,29 @@ Reads return `ModelProviderView`, containing safe endpoint/limit fields and `api_key_configured`, never `api_key`. It is distinct from `ModelProviderInput`: do not submit a read response as an update. Replacing a provider requires its full protocol, endpoint and key; MiniMax Code also requires both token limits. -Either default may be absent from a read: an Agent saved with only a provider has +Defaults may be absent from a read: an Agent saved with only a provider has no `harness`, and one saved with an empty extension reads `x_agents_core: {}`. -On update, omitting the extension preserves all defaults; omitting either nested -member preserves that member. A null provider clears its saved bundle, while +On update, omitted fields follow the [extension contract](../../contracts/agents-api/harness-selection.md). +A null provider clears its saved bundle, while `x_agents_core: null` clears the extension and its secret. An omitted harness defers protocol compatibility to Session admission. Existing Sessions retain their -configuration snapshots. Session inline `agent.x_agents_core` remains harness-only; -one-off provider overrides belong in the Session's top-level `x_agents_core`. +configuration snapshots. Session inline `agent.x_agents_core` accepts the harness +and its native `harness_config`; one-off provider overrides belong in the Session's +top-level `x_agents_core`. The model remains the ordinary `agent.model` field. +Use `{}` to clear native parameters; parameter names and validation belong to the +selected harness. See [the extension contract](../../contracts/agents-api/harness-selection.md) +for resolution and inheritance rules. -A Session read includes `agent.x_agents_core.harness` only when its Agent selected -a harness; Sessions on the deployment default keep the official Agent shape. +A Session read preserves its explicit harness and native parameters in +`agent.x_agents_core`; it does not invent an explicit harness selection. Administrators can inspect the configuration committed for one Session, including the provider selection, without reading credentials: ```ts const frozen = await admin.retrieveSessionExecutionConfiguration(projectId, session.id); console.log(frozen.model.value, frozen.model.source, frozen.harness.value); -// Deployment details are redacted; historical provider snapshots may be unavailable. +// Historical provider snapshots may be unavailable. if (frozen.model_provider.status === "available") { console.log(frozen.model_provider.configuration?.protocol); } @@ -63,3 +68,21 @@ if (frozen.model_provider.status === "available") { Configuration reads do not execute or wake Sessions. See [the query contract](../../contracts/agents-api/execution-configuration.md). + +Deployment defaults use the same provider, model and native-parameter fields through +`AdminClient`: + +```ts +await admin.setHarnessModelConfiguration("codex", { + model_provider: { protocol: "responses", base_url: modelBaseURL, api_key: modelAPIKey }, + model: "requested-model", + harness_config: { model_reasoning_effort: "high" }, +}); +const defaults = await admin.retrieveHarnessModelConfiguration("codex"); +console.log(defaults.model, defaults.harness_config, defaults.model_provider.api_key_configured); +``` + +`ModelConfigurationInput` carries the write-only key. `ModelConfigurationView` +contains a safe provider view, and `CoreHarness.model_configuration` exposes the +same deployment resource with observation timestamps. Existing Sessions keep their +frozen configuration when defaults change. diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index 712a53263..d4ada2acb 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -77,9 +77,9 @@ const routeCases: Array<[string, string, (client: AdminClient) => Promise client.issueExecutorCredential(projectId, resourceId, { key_id: keyId })], ["DELETE", `/projects/${projectId}/environments/${resourceId}/executor-credentials/${keyId}`, (client) => client.revokeExecutorCredential(projectId, resourceId, keyId)], ["GET", "/harnesses", (client) => client.listHarnesses()], - ["GET", "/harnesses/codex/model-provider", (client) => client.retrieveHarnessModelProvider("codex")], - ["PUT", "/harnesses/codex/model-provider", (client) => client.setHarnessModelProvider("codex", { protocol: "responses", base_url: "https://model.example/v1", api_key: "k" })], - ["DELETE", "/harnesses/codex/model-provider", (client) => client.deleteHarnessModelProvider("codex")], + ["GET", "/harnesses/codex/model-configuration", (client) => client.retrieveHarnessModelConfiguration("codex")], + ["PUT", "/harnesses/codex/model-configuration", (client) => client.setHarnessModelConfiguration("codex", { model: "test-model", model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key: "k" } })], + ["DELETE", "/harnesses/codex/model-configuration", (client) => client.deleteHarnessModelConfiguration("codex")], ]; describe("AdminClient transport boundary", () => { @@ -154,25 +154,25 @@ describe("AdminClient transport boundary", () => { }); it("manages deployment default model providers without ever reading a key", async () => { - const provider = { last_used_at: null, last_error_code: null, last_error_at: null, object: "core.model_provider", harness: "codex", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, updated_at: "2026-09-26T08:00:00Z" }; + const provider = { last_used_at: null, last_error_code: null, last_error_at: null, object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z" }; const harnesses = { object: "list", data: [ - { object: "core.harness", id: "claude_sdk", enabled: false, default: false, model_provider: null }, - { object: "core.harness", id: "codex", enabled: true, default: true, model_provider: provider }, + { object: "core.harness", id: "claude_sdk", enabled: false, default: false, model_configuration: null }, + { object: "core.harness", id: "codex", enabled: true, default: true, model_configuration: provider }, ] }; expect(await clientWith(harnesses).client.listHarnesses()).toEqual(harnesses); - expect(await clientWith(provider).client.retrieveHarnessModelProvider("codex")).toEqual(provider); + expect(await clientWith(provider).client.retrieveHarnessModelConfiguration("codex")).toEqual(provider); const { client, fetch } = clientWith(provider); - const input = { protocol: "responses", base_url: "https://model.example/v1", api_key: "write-only", context_window: 200000 } as const; - expect(await client.setHarnessModelProvider("codex", input)).toEqual(provider); + const input = { model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key: "write-only", context_window: 200000, max_output_tokens: 8000 } } as const; + expect(await client.setHarnessModelConfiguration("codex", input)).toEqual(provider); expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toEqual(input); - for (const unsafe of [{ ...provider, api_key: "leak" }, { ...provider, harness: "mcode" }, { ...provider, api_key_configured: false }, { ...provider, base_url: "http://model.example/v1" }, { ...provider, extra: 1 }]) { - await expect(clientWith(unsafe).client.retrieveHarnessModelProvider("codex")).rejects.toMatchObject({ code: "invalid_admin_response" }); + for (const unsafe of [{ ...provider, api_key: "leak" }, { ...provider, harness: "mcode" }, { ...provider, model_provider: { ...provider.model_provider, api_key_configured: false } }, { ...provider, model_provider: { ...provider.model_provider, base_url: "http://model.example/v1" } }, { ...provider, model_provider: { ...provider.model_provider, api_key: "leak" } }, { ...provider, model: "" }, { ...provider, harness_config: [] }, { ...provider, extra: 1 }]) { + await expect(clientWith(unsafe).client.retrieveHarnessModelConfiguration("codex")).rejects.toMatchObject({ code: "invalid_admin_response" }); } - for (const unsafe of [{ ...harnesses, data: [{ ...harnesses.data[1], model_provider: { ...provider, api_key: "leak" } }] }, { ...harnesses, data: [harnesses.data[1], harnesses.data[1]] }, { data: harnesses.data }]) { + for (const unsafe of [{ ...harnesses, data: [{ ...harnesses.data[1], model_configuration: { ...provider, api_key: "leak" } }] }, { ...harnesses, data: [harnesses.data[1], harnesses.data[1]] }, { data: harnesses.data }]) { await expect(clientWith(unsafe).client.listHarnesses()).rejects.toMatchObject({ code: "invalid_admin_response" }); } const deleted = vi.fn().mockImplementation(async () => new Response(null, { status: 204 })); - await expect(new AdminClient({ fetch: deleted }).deleteHarnessModelProvider("codex")).resolves.toBeUndefined(); + await expect(new AdminClient({ fetch: deleted }).deleteHarnessModelConfiguration("codex")).resolves.toBeUndefined(); }); it("accepts deployment-wide audit entries without a Project", async () => { diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 94c2e85bd..7db1c7834 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -12,10 +12,10 @@ import { CoreRequester } from "./core-request"; import { invalidAdminResponse, projectAdminProject, projectAdminKey, projectIssuedAdminKey, projectAdminPage, projectAdminDeleted, projectAdminSessionArchive, projectResourcePage, projectSavedAgent, projectSkill, projectSkillVersion, projectArtifact, projectSummary, projectAdminRuntimePage, projectResourceOwners, projectWriteOperations, projectAdminAudit, - projectExecutorCredentials, projectIssuedExecutorCredential, projectCoreHarnessList, projectHarnessModelProvider, projectInstallation, + projectExecutorCredentials, projectIssuedExecutorCredential, projectCoreHarnessList, projectHarnessModelConfiguration, projectInstallation, } from "./admin-projection"; import type { - CoreHarness, CoreHarnessKind, HarnessModelProvider, ModelProviderInput, PageOptions, ReadOptions, RuntimeHistoryQuery, SkillList, SkillVersionDeleted, + CoreHarness, CoreHarnessKind, HarnessModelConfiguration, ModelConfigurationInput, PageOptions, ReadOptions, RuntimeHistoryQuery, SkillList, SkillVersionDeleted, SkillVersionList, SourceFileList, VaultListOptions, } from "./types"; import type { @@ -285,26 +285,31 @@ export class AdminClient { await this.#response(`${scope(projectId)}/environments/${segment(environmentId)}/executor-credentials/${segment(keyId)}`, options, "DELETE"); } - /** Every harness this Core build supports, with its deployment default model provider or null. */ + /** Every harness this Core build supports, with its deployment default model configuration or null. */ async listHarnesses(options?: ReadOptions): Promise<{ object: "list"; data: CoreHarness[] }> { return projectCoreHarnessList(await this.#json("/harnesses", options)); } /** The harness's deployment default; Core answers 404 when none is set. The key is never returned. */ - async retrieveHarnessModelProvider(harness: CoreHarnessKind, options?: ReadOptions): Promise { - return projectHarnessModelProvider(await this.#json(`/harnesses/${segment(harness)}/model-provider`, options), harness); + async retrieveHarnessModelConfiguration(harness: CoreHarnessKind, options?: ReadOptions): Promise { + return projectHarnessModelConfiguration(await this.#json(`/harnesses/${segment(harness)}/model-configuration`, options), harness); } /** * Replaces the harness's deployment default with the complete bundle, including the write-only key. - * New openai_hosted and none Sessions freeze it; self_hosted Sessions never use it, and existing Sessions keep the provider they froze. + * New openai_hosted and none Sessions freeze it; self_hosted Sessions never use it, and existing Sessions keep the configuration they froze. */ - async setHarnessModelProvider(harness: CoreHarnessKind, input: ModelProviderInput, options?: ReadOptions): Promise { - const body: ModelProviderInput = { protocol: input.protocol, base_url: input.base_url, api_key: input.api_key }; - if (input.context_window !== undefined) body.context_window = input.context_window; - if (input.max_output_tokens !== undefined) body.max_output_tokens = input.max_output_tokens; - return projectHarnessModelProvider(await this.#json(`/harnesses/${segment(harness)}/model-provider`, options, "PUT", body), harness); - } - /** Removes the harness's deployment default; removing it again is safe. Existing Sessions keep their frozen provider. */ - async deleteHarnessModelProvider(harness: CoreHarnessKind, options?: ReadOptions): Promise { - await this.#response(`/harnesses/${segment(harness)}/model-provider`, options, "DELETE"); + async setHarnessModelConfiguration(harness: CoreHarnessKind, input: ModelConfigurationInput, options?: ReadOptions): Promise { + const { model_provider: provider } = input; + const body: ModelConfigurationInput = { + model: input.model, + model_provider: { protocol: provider.protocol, base_url: provider.base_url, api_key: provider.api_key }, + ...(input.harness_config === undefined ? {} : { harness_config: input.harness_config }), + }; + if (provider.context_window !== undefined) body.model_provider.context_window = provider.context_window; + if (provider.max_output_tokens !== undefined) body.model_provider.max_output_tokens = provider.max_output_tokens; + return projectHarnessModelConfiguration(await this.#json(`/harnesses/${segment(harness)}/model-configuration`, options, "PUT", body), harness); + } + /** Removes the harness's deployment default; removing it again is safe. Existing Sessions keep their frozen configuration. */ + async deleteHarnessModelConfiguration(harness: CoreHarnessKind, options?: ReadOptions): Promise { + await this.#response(`/harnesses/${segment(harness)}/model-configuration`, options, "DELETE"); } } diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 04dcae9f1..1d54882c8 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -2,7 +2,7 @@ import { AgentCoreError, projectRuntimeObservation, projectSavedAgentConfigurati import { projectTokenUsage } from "./usage-projection"; import { safeProvider } from "./execution-configuration-projection"; import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; -import type { CoreHarness, CoreHarnessKind, HarnessModelProvider, ProviderObservationErrorCode, ListPage, SavedAgent } from "./types"; +import type { CoreHarness, CoreHarnessKind, HarnessModelConfiguration, ProviderObservationErrorCode, ListPage, SavedAgent } from "./types"; import type { AdminAPIKey, AdminProject, AdminAuditPage, AdminSummary, AdminRuntimeObservation, RuntimeDiskObservation, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperationPage, AdminAuditResultID, AdminDeleted, AdminIssuedAPIKey, AdminPage, AdminSessionArchive, SessionArtifact, Skill, SkillVersion, ExecutorCredentialList, ExecutorConnection, IssuedExecutorCredential, CoreInstallation, CoreInstallationSetting } from "./admin-types"; export function invalidAdminResponse(): never { @@ -226,30 +226,31 @@ export function projectIssuedExecutorCredential(value: unknown, keyId: string, e const providerObservationErrors = new Set(["authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"]); const harnessKinds = new Set(["claude_sdk", "codex", "mcode"]); /** A deployment default model provider: exactly the safe view, never `api_key`. */ -export function projectHarnessModelProvider(value: unknown, harness?: CoreHarnessKind): HarnessModelProvider { - if (!isRecord(value) || !onlyFields(value, new Set(["object", "harness", "updated_at", "last_used_at", "last_error_code", "last_error_at", "protocol", "base_url", "context_window", "max_output_tokens", "api_key_configured"]))) return invalidAdminResponse(); - const { object, harness: kind, updated_at, last_used_at, last_error_code, last_error_at, ...view } = value; +export function projectHarnessModelConfiguration(value: unknown, harness?: CoreHarnessKind): HarnessModelConfiguration { + if (!isRecord(value) || !onlyFields(value, new Set(["object", "harness", "updated_at", "last_used_at", "last_error_code", "last_error_at", "model_provider", "model", "harness_config"]))) return invalidAdminResponse(); + const { object, harness: kind, updated_at, last_used_at, last_error_code, last_error_at, model_provider, model, harness_config } = value; if ((last_used_at !== null && (typeof last_used_at !== "string" || !date(last_used_at))) || (last_error_at !== null && (typeof last_error_at !== "string" || !date(last_error_at))) || (last_error_code !== null && (typeof last_error_code !== "string" || !providerObservationErrors.has(last_error_code))) || ((last_error_code === null) !== (last_error_at === null))) return invalidAdminResponse(); - if (object !== "core.model_provider" || typeof kind !== "string" || !harnessKinds.has(kind) || (harness !== undefined && kind !== harness) || + if (object !== "core.model_configuration" || typeof kind !== "string" || !harnessKinds.has(kind) || (harness !== undefined && kind !== harness) || typeof updated_at !== "string" || !date(updated_at)) return invalidAdminResponse(); - const provider = safeProvider(view, invalidAdminResponse); + if (typeof model !== "string" || !model.trim() || !isRecord(harness_config)) return invalidAdminResponse(); + const provider = safeProvider(model_provider, invalidAdminResponse); if (!provider.api_key_configured) return invalidAdminResponse(); - return { object, harness: kind as CoreHarnessKind, ...provider, updated_at, last_used_at, last_error_at, last_error_code: last_error_code as ProviderObservationErrorCode | null }; + return { object, harness: kind as CoreHarnessKind, model_provider: provider, model, harness_config: { ...harness_config }, updated_at, last_used_at, last_error_at, last_error_code: last_error_code as ProviderObservationErrorCode | null }; } export function projectCoreHarnessList(value: unknown): { object: "list"; data: CoreHarness[] } { const page = record(value, ["object", "data"]); if (page.object !== "list" || !Array.isArray(page.data)) return invalidAdminResponse(); const data = page.data.map((entry): CoreHarness => { - const harness = record(entry, ["object", "id", "enabled", "default", "model_provider"]); + const harness = record(entry, ["object", "id", "enabled", "default", "model_configuration"]); if (harness.object !== "core.harness" || typeof harness.id !== "string" || !harnessKinds.has(harness.id) || typeof harness.enabled !== "boolean" || typeof harness.default !== "boolean" || (harness.default && !harness.enabled)) return invalidAdminResponse(); const id = harness.id as CoreHarnessKind; return { object: "core.harness", id, enabled: harness.enabled, default: harness.default, - model_provider: harness.model_provider === null ? null : projectHarnessModelProvider(harness.model_provider, id), + model_configuration: harness.model_configuration === null ? null : projectHarnessModelConfiguration(harness.model_configuration, id), }; }); if (new Set(data.map((entry) => entry.id)).size !== data.length || data.filter((entry) => entry.default).length > 1) return invalidAdminResponse(); diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index 707fca6c0..cb3ada2fe 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -677,17 +677,17 @@ describe("OpenAIAgentsClient", () => { }); }); - it("accepts only a harness in a Session Agent's x_agents_core", async () => { + it("accepts harness and native model parameters in a Session Agent's x_agents_core", async () => { const retrieve = (core: unknown) => new OpenAIAgentsClient({ fetch: recordingFetch(jsonResponse({ ...sessionResource(), agent: core === undefined ? agentSnapshot() : { ...agentSnapshot(), x_agents_core: core }, }), []) }).retrieveSession("session"); - for (const core of [undefined, null, { harness: "codex" }]) { + for (const core of [undefined, null, {}, { harness: "codex" }, { harness_config: {} }]) { expect((await retrieve(core)).agent.x_agents_core).toEqual(core); } // Session reads never carry saved provider defaults; they live in execution_configuration. const provider = { protocol: "responses", base_url: "https://provider.test", api_key_configured: true }; - for (const core of [{}, { model_provider: provider }, { harness: "codex", model_provider: provider }, { harness: "codex", api_key: "leak" }]) { + for (const core of [{ model_provider: provider }, { harness: "codex", model_provider: provider }, { harness: "codex", api_key: "leak" }]) { await expect(retrieve(core)).rejects.toMatchObject({ status: 502, code: "invalid_session_resource" }); } }); diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index d2d7ad0ba..db333a178 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -267,8 +267,8 @@ const agentSnapshotFields = new Set([ "service_tier", "text", "tools", ]); const agentSnapshotAcceptedFields = new Set([...agentSnapshotFields, "x_agents_core"]); -const sessionAgentCoreFields = new Set(["harness"]); -const savedAgentCoreFields = new Set(["harness", "model_provider"]); +const sessionAgentCoreFields = new Set(["harness", "harness_config"]); +const savedAgentCoreFields = new Set(["harness", "model_provider", "harness_config"]); const multiAgentFields = new Set(["enabled", "max_concurrent_subagents"]); const reasoningFields = new Set(["effort", "summary"]); const textFields = new Set(["format", "verbosity"]); @@ -610,13 +610,18 @@ function invalidSessionResource(message = "OpenAgentCore returned an invalid Ses type AgentConfiguration = Omit & { x_agents_core?: Core | null }; -/** A Session's effective Agent reports only its persisted harness. */ +/** A Session's effective Agent preserves its explicit native selections. */ function projectSessionAgentCore(value: unknown): AgentsCoreSelection | null | undefined { if (value === undefined || value === null) return value; - if (!isRecord(value) || !exactFields(value, sessionAgentCoreFields) || !isHarnessKind(value.harness)) { + if (!isRecord(value) || !onlyFields(value, sessionAgentCoreFields) || + (hasOwn(value, "harness") && !isHarnessKind(value.harness)) || + (hasOwn(value, "harness_config") && !isRecord(value.harness_config))) { return invalidSessionResource(); } - return { harness: value.harness }; + return { + ...(hasOwn(value, "harness") ? { harness: value.harness as CoreHarnessKind } : {}), + ...(hasOwn(value, "harness_config") ? { harness_config: { ...value.harness_config as Record } } : {}), + }; } /** @@ -627,13 +632,15 @@ function projectSavedAgentCore(value: unknown): SavedAgentCore | null | undefine if (value === undefined || value === null) return value; if ( !isRecord(value) || !onlyFields(value, savedAgentCoreFields) || - (hasOwn(value, "harness") && !isHarnessKind(value.harness)) + (hasOwn(value, "harness") && !isHarnessKind(value.harness)) || + (hasOwn(value, "harness_config") && !isRecord(value.harness_config)) ) return invalidSessionResource(); return { ...(hasOwn(value, "harness") ? { harness: value.harness as CoreHarnessKind } : {}), ...(hasOwn(value, "model_provider") ? { model_provider: safeProvider(value.model_provider, invalidSessionResource) } : {}), + ...(hasOwn(value, "harness_config") ? { harness_config: { ...value.harness_config as Record } } : {}), }; } diff --git a/packages/agents-client/src/execution-configuration-projection.ts b/packages/agents-client/src/execution-configuration-projection.ts index 6ae24dc72..5ae4c5000 100644 --- a/packages/agents-client/src/execution-configuration-projection.ts +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -46,10 +46,13 @@ export function safeProvider(value: unknown, invalid: Invalid): ModelProviderVie } export function projectExecutionConfiguration(value: unknown, sessionId: string, invalid: Invalid): SessionExecutionConfiguration { - if (!isRecord(value) || !exactFields(value, new Set(["object", "schema_version", "session_id", "model", "harness", "model_provider"])) || + if (!isRecord(value) || !exactFields(value, new Set(["object", "schema_version", "session_id", "model", "harness", "model_provider", "harness_config"])) || value.object !== "agent.session.execution_configuration" || value.schema_version !== 1 || typeof value.session_id !== "string" || !canonicalUuid(value.session_id) || !sameResourceId(value.session_id, sessionId) || !isRecord(value.model_provider) || !exactFields(value.model_provider, new Set(["source", "status", "configuration"]))) return invalid(); + const native = value.harness_config; + if (!isRecord(native) || !exactFields(native, selectionFields) || !isRecord(native.value) || + !sources.has(String(native.source)) || (native.source === "unknown" && Object.keys(native.value).length !== 0)) return invalid(); const provider = value.model_provider; let configuration: ModelProviderView | null = null; if (provider.status === "available" && (provider.source === "session" || provider.source === "agent" || provider.source === "deployment")) { @@ -59,6 +62,7 @@ export function projectExecutionConfiguration(value: unknown, sessionId: string, return { object: "agent.session.execution_configuration", schema_version: 1, session_id: value.session_id, model: selection(value.model, invalid), harness: selection(value.harness, invalid), + harness_config: { value: { ...native.value }, source: native.source as ExecutionConfigurationSource }, model_provider: { source: provider.source as ExecutionConfigurationSource, status: provider.status as SessionExecutionConfiguration["model_provider"]["status"], configuration }, }; } diff --git a/packages/agents-client/src/execution-configuration.test.ts b/packages/agents-client/src/execution-configuration.test.ts index 4e4e81972..c3938f2f9 100644 --- a/packages/agents-client/src/execution-configuration.test.ts +++ b/packages/agents-client/src/execution-configuration.test.ts @@ -7,6 +7,7 @@ const id = "013773a9-44b9-4f84-baca-b51c04a01201"; const snapshot: SessionExecutionConfiguration = { object: "agent.session.execution_configuration", schema_version: 1, session_id: id, model: { value: "frozen-model", source: "session" }, harness: { value: "codex", source: "agent" }, + harness_config: { value: { model_reasoning_effort: "high" }, source: "agent" }, model_provider: { status: "available", source: "agent", configuration: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true } }, }; function clientReturning(value: unknown, seen?: (url: string, init?: RequestInit) => void) { @@ -52,6 +53,9 @@ describe("frozen execution configuration", () => { (value: any) => { value.model_provider.configuration.base_url += "?key=secret-canary"; }, (value: any) => { value.session_id = "wrong-session"; }, (value: any) => { value.model.source = "guessed"; }, + (value: any) => { value.harness_config.value = []; }, + (value: any) => { value.harness_config.source = "unknown"; }, + (value: any) => { value.harness_config.source = "guessed"; }, (value: any) => { value.model_provider.configuration.protocol = "chat-completions"; }, ])("rejects unsafe or inconsistent responses without echoing them", async (mutate) => { const value = structuredClone(snapshot); mutate(value); diff --git a/packages/agents-client/src/protocol-types.test.ts b/packages/agents-client/src/protocol-types.test.ts index ca212ddf2..7756b9ce1 100644 --- a/packages/agents-client/src/protocol-types.test.ts +++ b/packages/agents-client/src/protocol-types.test.ts @@ -20,6 +20,9 @@ import type { AgentSnapshot, ModelProviderInput, ModelProviderView, + ModelConfigurationInput, + ModelConfigurationView, + HarnessConfig, AgentCore, AgentSession, AgentEnvironmentResource, @@ -211,6 +214,10 @@ describe("Core harness selection extension", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().not.toExtend(); expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().not.toExtend(); expectTypeOf().toEqualTypeOf(); }); diff --git a/packages/agents-client/src/provider-observations.test.ts b/packages/agents-client/src/provider-observations.test.ts index 212f82e24..eb9fc6e60 100644 --- a/packages/agents-client/src/provider-observations.test.ts +++ b/packages/agents-client/src/provider-observations.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "vitest"; -import { projectHarnessModelProvider } from "./admin-projection"; +import { projectHarnessModelConfiguration } from "./admin-projection"; -const provider = { object: "core.model_provider", harness: "codex", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, updated_at: "2026-09-26T08:00:00Z", last_used_at: null, last_error_code: null, last_error_at: null }; +const provider = { object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z", last_used_at: null, last_error_code: null, last_error_at: null }; describe("deployment default observations", () => { it("preserves null and safe observations", () => { - expect(projectHarnessModelProvider(provider)).toEqual(provider); + expect(projectHarnessModelConfiguration(provider)).toEqual(provider); const observed = { ...provider, last_used_at: "2026-09-26T08:01:00Z", last_error_at: "2026-09-26T08:00:30Z", last_error_code: "authentication_error" }; - expect(projectHarnessModelProvider(observed)).toEqual(observed); + expect(projectHarnessModelConfiguration(observed)).toEqual(observed); }); it("rejects private state, raw codes and inconsistent pairs", () => { for (const extra of [{ revision: "private" }, { recovery_pending: true }, { last_error_code: "raw provider secret", last_error_at: provider.updated_at }, { last_error_code: "context_length_exceeded", last_error_at: provider.updated_at }, { last_error_code: "server_error" }, { last_error_at: provider.updated_at }, { last_used_at: "invalid" }, { last_used_at: undefined }]) { - expect(() => projectHarnessModelProvider({ ...provider, ...extra })).toThrow(); + expect(() => projectHarnessModelConfiguration({ ...provider, ...extra })).toThrow(); } }); }); diff --git a/packages/agents-client/src/saved-agent-defaults.test.ts b/packages/agents-client/src/saved-agent-defaults.test.ts index a50089a9b..573b822ef 100644 --- a/packages/agents-client/src/saved-agent-defaults.test.ts +++ b/packages/agents-client/src/saved-agent-defaults.test.ts @@ -8,6 +8,7 @@ describe("saved Agent execution defaults", () => { const calls: unknown[] = []; const safe: SavedAgentCore = { harness: "codex", + harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol, base_url: "https://model.example/v1", api_key_configured: true, }, @@ -24,20 +25,22 @@ describe("saved Agent execution defaults", () => { const provider: ModelProviderInput = { protocol, base_url: "https://model.example/v1", api_key: "write-only-fixture", }; - const agent = await client.createAgent({ model: "example-model", x_agents_core: { harness: "codex", model_provider: provider } }); + const agent = await client.createAgent({ model: "example-model", x_agents_core: { harness: "codex", harness_config: { model_reasoning_effort: "high" }, model_provider: provider } }); expect(agent.x_agents_core).toEqual(safe); expect(JSON.stringify(agent)).not.toContain("write-only-fixture"); await client.updateAgent(agent.id, { model: "new-model" }); await client.updateAgent(agent.id, { x_agents_core: { harness: "codex" } }); await client.updateAgent(agent.id, { x_agents_core: { model_provider: provider } }); await client.updateAgent(agent.id, { x_agents_core: { model_provider: null } }); + await client.updateAgent(agent.id, { x_agents_core: { harness_config: {} } }); await client.updateAgent(agent.id, { x_agents_core: null }); expect(calls).toEqual([ - { model: "example-model", x_agents_core: { harness: "codex", model_provider: provider } }, + { model: "example-model", x_agents_core: { harness: "codex", harness_config: { model_reasoning_effort: "high" }, model_provider: provider } }, { model: "new-model" }, { x_agents_core: { harness: "codex" } }, { x_agents_core: { model_provider: provider } }, { x_agents_core: { model_provider: null } }, + { x_agents_core: { harness_config: {} } }, { x_agents_core: null }, ]); }); @@ -64,3 +67,24 @@ describe("saved Agent execution defaults", () => { ]); }); }); + + +describe("native model configuration projections", () => { + it("preserves explicit empty and nested native parameters on saved and inline Agent reads", async () => { + const { projectAgentSnapshot, projectSavedAgentConfiguration } = await import("./client"); + const agent = { + id: "test-agent", model: "test-model", name: null, instructions: null, multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: { effort: null, summary: null }, service_tier: "auto", text: { format: { type: "text" }, verbosity: "medium" }, tools: [], + }; + for (const harness_config of [{}, { effort: "high", thinking: { type: "adaptive" } }]) { + const value = { ...agent, x_agents_core: { harness_config } }; + expect(projectAgentSnapshot(value).x_agents_core).toEqual({ harness_config }); + expect(projectSavedAgentConfiguration(value).x_agents_core).toEqual({ harness_config }); + } + for (const harness_config of [null, [], "invalid"]) { + const value = { ...agent, x_agents_core: { harness_config } }; + expect(() => projectAgentSnapshot(value)).toThrow(); + expect(() => projectSavedAgentConfiguration(value)).toThrow(); + } + }); +}); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 4f40edc0f..deadf37f2 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -713,7 +713,7 @@ export interface TolerantSessionList { } export interface CreateSessionInput { - x_agents_core?: { model_provider?: ModelProviderInput | null }; + x_agents_core?: { model_provider?: ModelProviderInput | null; harness_config?: HarnessConfig }; agent_id?: string; agent?: InlineAgentInput; environment: AgentEnvironmentInput; @@ -1206,40 +1206,58 @@ export interface ModelProviderView { /** A harness's deployment default model provider in Core. The key is never returned. */ export type ProviderObservationErrorCode = "authentication_error" | "connection_failed" | "rate_limit_exceeded" | "usage_limit_exceeded" | "server_overloaded" | "server_error" | "resource_not_found" | "request_timeout" | "invalid_request"; -export interface HarnessModelProvider extends ModelProviderView { +/** Native model parameters validated by the selected harness. An empty object clears them. */ +export type HarnessConfig = Record; + +export interface ModelConfigurationInput { + model_provider: ModelProviderInput; + model: string; + harness_config?: HarnessConfig; +} + +export interface ModelConfigurationView { + model_provider: ModelProviderView; + model: string; + harness_config: HarnessConfig; +} + +export interface HarnessModelConfiguration extends ModelConfigurationView { last_used_at: string | null; last_error_code: ProviderObservationErrorCode | null; last_error_at: string | null; - object: "core.model_provider"; + object: "core.model_configuration"; harness: CoreHarnessKind; updated_at: string; } /** * A harness this Core build supports. `enabled` and `default` are read-only views of - * the process configuration; `model_provider` is the deployment default, or null. + * the process configuration; `model_configuration` is the deployment default, or null. */ export interface CoreHarness { object: "core.harness"; id: CoreHarnessKind; enabled: boolean; default: boolean; - model_provider: HarnessModelProvider | null; + model_configuration: HarnessModelConfiguration | null; } -/** Omitted members preserve saved defaults on update; null provider clears it. */ +/** Native parameter inheritance follows the extension contract; null provider clears it. */ export interface SavedAgentCoreInput { harness?: CoreHarnessKind; model_provider?: ModelProviderInput | null; + harness_config?: HarnessConfig; } export interface SavedAgentCore { harness?: CoreHarnessKind; model_provider?: ModelProviderView; + harness_config?: HarnessConfig; } export interface AgentsCoreSelection { - harness: CoreHarnessKind; + harness?: CoreHarnessKind; + harness_config?: HarnessConfig; } export type ExecutionConfigurationSource = "session" | "agent" | "deployment" | "unknown"; @@ -1251,6 +1269,7 @@ export interface SessionExecutionConfiguration { session_id: string; model: { value: string | null; source: ExecutionConfigurationSource }; harness: { value: string | null; source: ExecutionConfigurationSource }; + harness_config: { value: HarnessConfig; source: ExecutionConfigurationSource }; model_provider: { source: ExecutionConfigurationSource; status: "available" | "redacted" | "unavailable"; From b965f26ccb1ac837d8ee6c98c4e07fee74fee32a Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 08:41:25 +0000 Subject: [PATCH 03/13] docs: define harness and model transport capability coordination --- apps/parsar-daemon/internal/agent/harness.go | 25 ++++++++++++++++++++ contracts/agents-api/harness-onboarding.md | 8 +++++++ 2 files changed, 33 insertions(+) diff --git a/apps/parsar-daemon/internal/agent/harness.go b/apps/parsar-daemon/internal/agent/harness.go index 145f578e7..034d0c8a2 100644 --- a/apps/parsar-daemon/internal/agent/harness.go +++ b/apps/parsar-daemon/internal/agent/harness.go @@ -52,6 +52,31 @@ import ( // addresses or short-lived credentials. Configuration defaults and inheritance // belong to the public API contract. +// Configuration support is declared once by each adapter in internal/harnessconfig: +// accepted upstream protocols, native protocols, token-limit requirements and its +// native model-parameter validator. Core admission, Runtime and modeltransport +// consume that declaration; adding a Harness must not introduce another public +// configuration shape or a Harness-name branch in orchestration. +// +// modeltransport owns route selection and preservation requirements. JSON parsing, +// adapter acceptance, conversion qualification and upstream model support are +// separate facts. A registered translator alone does not prove preservation. +// Native routes keep the original protocol; conversion routes must reject an +// unqualified requirement before forwarding model input. Nonempty native parameters, +// structured output, tool search, native web search and nondefault verbosity are +// currently native-route-only. Existing text, ordinary tools and streaming retain +// their qualified conversion paths; no model-name heuristic establishes support. +// Direct transport does not prove that the selected remote model accepts a native +// parameter: the native Harness/provider remains responsible for that response. +// +// Input capability uses the existing MessageImages/MessageImagePlacements and +// proto.MessageInput contracts, not another image type in model configuration. +// Configuration preparation checks known requirements. Each Turn, steer and tool +// result must also check newly introduced input requirements against the fixed +// route before native submission or proxy forwarding. Unqualified image forms +// must fail explicitly, never be dropped or reduced to text. The proxy performs +// the final request check before forwarding; it cannot silently weaken semantics. + // Required execution lifecycle. // ExecutorFactory prepares without model input. A failed factory retains any diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 50c477366..23ed0961e 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -71,6 +71,14 @@ enabled thinking, and an optional positive integer `budgetTokens` only for These are native settings, not a shared reasoning vocabulary; model availability and provider support remain the selected harness's responsibility. +Native protocol and parameter declarations also feed Core administration's small +configuration-support descriptor. `modeltransport` selects and validates routes +from those declarations. Its conversion qualification is distinct from adapter +acceptance and remote-model support. Follow the route and per-input requirements +in `agent/harness.go`; do not add a second model capability registry or infer +capabilities from model names. Nonempty `harness_config` currently requires a native +protocol route because the translator has no loss report for those parameters. + ## Required adapter interfaces [`agent/harness.go`](../../apps/parsar-daemon/internal/agent/harness.go) is the From 226adbf71746ba09ac48a7ec13569e497e412a00 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 08:46:14 +0000 Subject: [PATCH 04/13] feat(client): expose harness model configuration support --- .../agents-client/src/admin-client.test.ts | 4 ++-- .../agents-client/src/admin-projection.ts | 17 +++++++++++++- .../src/provider-observations.test.ts | 22 ++++++++++++++++++- packages/agents-client/src/types.ts | 10 +++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/agents-client/src/admin-client.test.ts b/packages/agents-client/src/admin-client.test.ts index d4ada2acb..407dc62b9 100644 --- a/packages/agents-client/src/admin-client.test.ts +++ b/packages/agents-client/src/admin-client.test.ts @@ -156,8 +156,8 @@ describe("AdminClient transport boundary", () => { it("manages deployment default model providers without ever reading a key", async () => { const provider = { last_used_at: null, last_error_code: null, last_error_at: null, object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z" }; const harnesses = { object: "list", data: [ - { object: "core.harness", id: "claude_sdk", enabled: false, default: false, model_configuration: null }, - { object: "core.harness", id: "codex", enabled: true, default: true, model_configuration: provider }, + { object: "core.harness", id: "claude_sdk", enabled: false, default: false, model_configuration: null, model_configuration_support: { protocols: ["anthropic", "responses", "chat_completions"], native_protocols: ["anthropic"], accepts_harness_config: true, token_limits_required: false } }, + { object: "core.harness", id: "codex", enabled: true, default: true, model_configuration: provider, model_configuration_support: { protocols: ["anthropic", "responses", "chat_completions"], native_protocols: ["responses"], accepts_harness_config: true, token_limits_required: false } }, ] }; expect(await clientWith(harnesses).client.listHarnesses()).toEqual(harnesses); expect(await clientWith(provider).client.retrieveHarnessModelConfiguration("codex")).toEqual(provider); diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 1d54882c8..63dd84b4d 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -240,16 +240,31 @@ export function projectHarnessModelConfiguration(value: unknown, harness?: CoreH if (!provider.api_key_configured) return invalidAdminResponse(); return { object, harness: kind as CoreHarnessKind, model_provider: provider, model, harness_config: { ...harness_config }, updated_at, last_used_at, last_error_at, last_error_code: last_error_code as ProviderObservationErrorCode | null }; } +function projectModelConfigurationSupport(value: unknown): CoreHarness["model_configuration_support"] { + const support = record(value, ["protocols", "native_protocols", "accepts_harness_config", "token_limits_required"]); + const known = new Set(["anthropic", "responses", "chat_completions"]); + const protocols = support.protocols; + const native = support.native_protocols; + if (!Array.isArray(protocols) || protocols.length === 0 || protocols.some((protocol) => !known.has(protocol)) || + new Set(protocols).size !== protocols.length || !Array.isArray(native) || native.length === 0 || + native.some((protocol) => !protocols.includes(protocol)) || new Set(native).size !== native.length || + typeof support.accepts_harness_config !== "boolean" || typeof support.token_limits_required !== "boolean") return invalidAdminResponse(); + return { + protocols: [...protocols], native_protocols: [...native], + accepts_harness_config: support.accepts_harness_config, token_limits_required: support.token_limits_required, + }; +} export function projectCoreHarnessList(value: unknown): { object: "list"; data: CoreHarness[] } { const page = record(value, ["object", "data"]); if (page.object !== "list" || !Array.isArray(page.data)) return invalidAdminResponse(); const data = page.data.map((entry): CoreHarness => { - const harness = record(entry, ["object", "id", "enabled", "default", "model_configuration"]); + const harness = record(entry, ["object", "id", "enabled", "default", "model_configuration", "model_configuration_support"]); if (harness.object !== "core.harness" || typeof harness.id !== "string" || !harnessKinds.has(harness.id) || typeof harness.enabled !== "boolean" || typeof harness.default !== "boolean" || (harness.default && !harness.enabled)) return invalidAdminResponse(); const id = harness.id as CoreHarnessKind; return { object: "core.harness", id, enabled: harness.enabled, default: harness.default, + model_configuration_support: projectModelConfigurationSupport(harness.model_configuration_support), model_configuration: harness.model_configuration === null ? null : projectHarnessModelConfiguration(harness.model_configuration, id), }; }); diff --git a/packages/agents-client/src/provider-observations.test.ts b/packages/agents-client/src/provider-observations.test.ts index eb9fc6e60..010c45ccf 100644 --- a/packages/agents-client/src/provider-observations.test.ts +++ b/packages/agents-client/src/provider-observations.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { projectHarnessModelConfiguration } from "./admin-projection"; +import { projectCoreHarnessList, projectHarnessModelConfiguration } from "./admin-projection"; const provider = { object: "core.model_configuration", harness: "codex", model: "test-model", harness_config: { model_reasoning_effort: "high" }, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, updated_at: "2026-09-26T08:00:00Z", last_used_at: null, last_error_code: null, last_error_at: null }; describe("deployment default observations", () => { @@ -14,3 +14,23 @@ describe("deployment default observations", () => { } }); }); + +describe("adapter model configuration support", () => { + const support = { protocols: ["anthropic", "responses", "chat_completions"], native_protocols: ["responses", "anthropic"], accepts_harness_config: true, token_limits_required: false }; + const harness = { object: "core.harness", id: "codex", enabled: false, default: false, model_configuration: null, model_configuration_support: support }; + it("preserves build declarations and native default order without inferring readiness", () => { + const value = { object: "list", data: [harness] }; + expect(projectCoreHarnessList(value)).toEqual(value); + const requiredLimits = { ...harness, id: "mcode", model_configuration_support: { ...support, native_protocols: ["anthropic"], accepts_harness_config: false, token_limits_required: true } }; + expect(projectCoreHarnessList({ object: "list", data: [requiredLimits] }).data).toEqual([requiredLimits]); + }); + it("rejects missing, malformed or contradictory declarations", () => { + for (const value of [undefined, null, {}, { ...support, protocols: [] }, { ...support, protocols: ["unknown"] }, + { ...support, protocols: ["responses", "responses"] }, { ...support, native_protocols: [] }, + { ...support, native_protocols: ["unknown"] }, { ...support, protocols: ["responses"], native_protocols: ["anthropic"] }, + { ...support, native_protocols: ["responses", "responses"] }, { ...support, accepts_harness_config: "true" }, + { ...support, token_limits_required: null }, { ...support, ready: true }]) { + expect(() => projectCoreHarnessList({ object: "list", data: [{ ...harness, model_configuration_support: value }] })).toThrow(); + } + }); +}); diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index deadf37f2..765a73f84 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -1230,6 +1230,15 @@ export interface HarnessModelConfiguration extends ModelConfigurationView { updated_at: string; } +/** Adapter build support, independent of runtime readiness or model availability. */ +export interface ModelConfigurationSupport { + protocols: ModelProviderInput["protocol"][]; + /** Ordered native protocols; the first is the default. */ + native_protocols: ModelProviderInput["protocol"][]; + accepts_harness_config: boolean; + token_limits_required: boolean; +} + /** * A harness this Core build supports. `enabled` and `default` are read-only views of * the process configuration; `model_configuration` is the deployment default, or null. @@ -1240,6 +1249,7 @@ export interface CoreHarness { enabled: boolean; default: boolean; model_configuration: HarnessModelConfiguration | null; + model_configuration_support: ModelConfigurationSupport; } /** Native parameter inheritance follows the extension contract; null provider clears it. */ From 9fefb0557d914abca6a21017004ed714b69aa3ff Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 08:47:57 +0000 Subject: [PATCH 05/13] feat(web): configure default models through shared harness contract --- apps/web/DESIGN.md | 12 +- apps/web/PRODUCT.md | 2 +- apps/web/e2e/access.spec.ts | 11 +- apps/web/e2e/fixture-console.mjs | 20 +- apps/web/e2e/provider-observations.spec.ts | 2 +- apps/web/e2e/system.spec.ts | 91 ++++--- .../features/overview/getting-started.test.ts | 16 +- .../src/features/overview/getting-started.ts | 2 +- .../features/system/DefaultModelsSection.tsx | 211 +--------------- apps/web/src/features/system/HarnessCard.tsx | 10 +- .../features/system/ModelProviderDialog.tsx | 230 ++++++++++++++++++ .../features/system/ProviderObservations.tsx | 6 +- .../system/provider-observations.test.tsx | 4 +- apps/web/src/features/system/system.css | 18 ++ apps/web/src/i18n/locales/en/core-errors.ts | 3 + apps/web/src/i18n/locales/en/onboarding.ts | 2 +- apps/web/src/i18n/locales/en/overview.ts | 4 +- apps/web/src/i18n/locales/en/system.ts | 50 ++-- .../web/src/i18n/locales/zh-CN/core-errors.ts | 3 + apps/web/src/i18n/locales/zh-CN/onboarding.ts | 2 +- apps/web/src/i18n/locales/zh-CN/overview.ts | 4 +- apps/web/src/i18n/locales/zh-CN/system.ts | 50 ++-- apps/web/src/lib/core-error.ts | 2 +- 23 files changed, 436 insertions(+), 319 deletions(-) create mode 100644 apps/web/src/features/system/ModelProviderDialog.tsx diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index af7e292af..a44ccdcbd 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -825,13 +825,19 @@ this connection-scoped state. ### System page Four sections, each saying where it changes. Installation: the public address, API -base URL, installation ID and source commit as a fact card. Default model provider, the one +base URL, installation ID and source commit as a fact card. Default model configuration, the one section changed here: one card per harness in an auto-fill grid, its header holding the harness name and outline actions (Set, or Replace and Clear); fact rows give the harness's read-only startup state (a status dot and a Default pill, its source behind -a help tip), then the provider's protocol, base URL, whether a key is configured, +a help tip), then the default model ID, provider protocol, base URL, whether a key is configured, token limits when set and the update time, or Not set. Set and Replace open one form -dialog; the key field is a required password input, never prefilled or shown and +dialog. The model ID is required; advanced settings disclose an optional JSON object +editor with formatting and inline syntax errors, plus token limits. The existing +harness discovery response supplies supported protocols, native protocols, JSON +support and required limits from one adapter declaration. The form uses those +fields without harness-specific branches. Nonempty JSON requires a native protocol; +the form explains an incompatible selection beside the editor. Help tips explain +the scope of native settings. The key field is a required password input, never prefilled or shown and forgotten when the form closes. Core's rejection stays in red inside the form; Clear is a ConfirmDialog. Usage details opens Core’s observations in a separate dialog. Sandboxes: one navigation row to the Sandbox configuration secondary page; do not diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index d97b1ea73..fda356583 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -85,7 +85,7 @@ workbench. individual node operations). Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Removal offers the host's uninstall command. System owns installation - facts, each harness's default model provider, startup settings, and a link to + facts, each harness's default model configuration, startup settings, and a link to the Sandbox configuration secondary page. That page owns setup, resource edits, rollout details and reset. Setup selects a backend, size and Runtime, then asks for a deliberate save; own-machine setup continues to Add node. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index ead3fbd2e..f22039b3c 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -53,7 +53,7 @@ test("opens a fresh install on the Overview's Getting started: a project and its const step = (name: string) => page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: name }); // The fixture deployment already has a ready node. await expect(step("Get sandboxes ready")).toContainText("Done"); - await expect(step("Set a default model provider")).toContainText("To do"); + await expect(step("Set a default model configuration")).toContainText("To do"); await expect(step("Create a project and issue a key")).toContainText("To do"); await expect(step("Run the first Session")).toContainText("To do"); @@ -103,21 +103,22 @@ test("opens a fresh install on the Overview's Getting started: a project and its expect(stored).not.toContain(FIXTURE_CORE_KEY); }); -test("leads from Getting started to the default model provider, and counts it done once the default harness has one", async ({ page, request }) => { +test("leads from Getting started to the default model configuration, and counts it done once the default harness has one", async ({ page, request }) => { await openConsole(page, request, "overview", { fresh: true }); - const step = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Set a default model provider" }); + const step = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Set a default model configuration" }); await expect(step).toContainText("To do"); await step.getByRole("button", { name: "Open System" }).click(); await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeVisible(); // It arrives on the default harness's action. - const set = page.getByRole("button", { name: "Set the default model provider for Codex" }); + const set = page.getByRole("button", { name: "Set the default model configuration for Codex" }); await expect(set).toBeFocused(); // Only the page body scrolls to it: the page header stays in view. await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeInViewport(); await set.click(); - const form = page.getByRole("dialog", { name: "Set default model provider for Codex" }); + const form = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); await form.getByLabel("Base URL").fill("https://model.example/v1"); await form.getByLabel("API key").fill("sk-fixture-getting-started"); + await form.getByLabel("Default model ID").fill("fixture-model"); await form.getByRole("button", { name: "Save" }).click(); await expect(form).toBeHidden(); await page.getByRole("button", { name: "Overview", exact: true }).click(); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index af794ea97..19319238b 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -117,7 +117,7 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d // The demo deployment's default harness has a default model; a fresh install has none. harnesses: { claude_sdk: { enabled: true, default: false, provider: null }, - codex: { enabled: true, default: true, provider: fresh ? null : { object: "core.model_provider", harness: "codex", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, last_used_at: null, last_error_code: null, last_error_at: null, updated_at: new Date((now - 86400) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") } }, + codex: { enabled: true, default: true, provider: fresh ? null : { object: "core.model_configuration", harness: "codex", model: "fixture-model", harness_config: {}, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, last_used_at: null, last_error_code: null, last_error_at: null, updated_at: new Date((now - 86400) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") } }, mcode: { enabled: false, default: false, provider: null }, }, // "none": the deployment is not configured yet, so the Nodes page offers setup. @@ -496,7 +496,7 @@ function registeredNode(nodeId) { return { rollout: { state: "unknown", ready_generation: null }, id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), enrollment_id, online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; } -const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-provider$/; +const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-configuration$/; const PROVIDER_FIELDS = new Set(["protocol", "base_url", "api_key", "context_window", "max_output_tokens"]); /** Core's one message for a body that is not a complete provider; it never echoes a value. */ const PROVIDER_SHAPE = "The body must be a complete model provider: protocol, base_url, api_key and optional nonnegative context_window and max_output_tokens."; @@ -526,7 +526,8 @@ function providerProblem(harness, input) { * DELETE is 204 and safe to repeat. A disabled harness may still be configured. */ async function harnessRoute(request, response, path) { - const view = (id) => ({ object: "core.harness", id, enabled: state.harnesses[id].enabled, default: state.harnesses[id].default, model_provider: state.harnesses[id].provider }); + const support = (id) => ({ protocols: ["anthropic", "responses", "chat_completions"], native_protocols: id === "codex" ? ["responses"] : id === "claude_sdk" ? ["anthropic"] : ["anthropic", "responses", "chat_completions"], accepts_harness_config: id !== "mcode", token_limits_required: id === "mcode" }); + const view = (id) => ({ model_configuration_support: support(id), object: "core.harness", id, enabled: state.harnesses[id].enabled, default: state.harnesses[id].default, model_configuration: state.harnesses[id].provider }); if (path === "/harnesses" && request.method === "GET") return send(response, 200, { object: "list", data: Object.keys(state.harnesses).map(view) }); const match = path.match(HARNESS_PROVIDER); const harness = match && Object.hasOwn(state.harnesses, match[1]) ? match[1] : null; @@ -551,14 +552,19 @@ async function harnessRoute(request, response, path) { } input ??= {}; if (typeof input !== "object" || Array.isArray(input)) return error(response, 400, `Invalid type: expected an object, but got ${jsonKind(input)} instead.`, "invalid_request_error"); - const problem = providerProblem(harness, input); + if (typeof input.model !== "string" || !input.model.trim()) return error(response, 400, "A model ID is required.", "model_configuration_model_invalid"); + if (input.harness_config !== undefined && (!input.harness_config || typeof input.harness_config !== "object" || Array.isArray(input.harness_config))) return error(response, 400, "Harness configuration must be an object.", "harness_config_invalid"); + if (Object.keys(input.harness_config ?? {}).length && !support(harness).native_protocols.includes(input.model_provider?.protocol)) return error(response, 400, "The selected route cannot preserve native model settings.", "model_configuration_route_unsupported"); + const problem = providerProblem(harness, input.model_provider ?? {}); if (problem) return error(response, 400, problem, "invalid_request_error"); // As Core's error mapping: sealing the key needs the credential encryption key. if (!state.credentialKey) return error(response, 503, "Credential encryption is not configured on this service.", "credential_storage_unavailable"); entry.provider = { - object: "core.model_provider", harness, protocol: input.protocol, base_url: input.base_url, api_key_configured: true, - ...(input.context_window ? { context_window: input.context_window } : {}), - ...(input.max_output_tokens ? { max_output_tokens: input.max_output_tokens } : {}), + object: "core.model_configuration", harness, model: input.model, harness_config: input.harness_config ?? {}, + model_provider: { protocol: input.model_provider.protocol, base_url: input.model_provider.base_url, api_key_configured: true, + ...(input.model_provider.context_window ? { context_window: input.model_provider.context_window } : {}), + ...(input.model_provider.max_output_tokens ? { max_output_tokens: input.model_provider.max_output_tokens } : {}), + }, last_used_at: null, last_error_code: null, last_error_at: null, updated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), }; diff --git a/apps/web/e2e/provider-observations.spec.ts b/apps/web/e2e/provider-observations.spec.ts index b3fd750d3..c45ab1aca 100644 --- a/apps/web/e2e/provider-observations.spec.ts +++ b/apps/web/e2e/provider-observations.spec.ts @@ -10,7 +10,7 @@ test("default provider observations are optional details and recover only on Cor if (fail) return route.fulfill({ status: 503, json: { error: { code: "internal_error", message: "Read unavailable", type: "server_error", param: null } } }); const response = await route.fetch(); const data = await response.json(); - for (const harness of data.data) if (harness.model_provider) Object.assign(harness.model_provider, observation); + for (const harness of data.data) if (harness.model_configuration) Object.assign(harness.model_configuration, observation); await route.fulfill({ response, json: data }); }); await openConsole(page, request, "system"); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts index 52e485da6..5639e17b6 100644 --- a/apps/web/e2e/system.spec.ts +++ b/apps/web/e2e/system.spec.ts @@ -6,10 +6,10 @@ test.afterEach(async ({ request }) => expectManagementBoundary(request)); const KEY = "sk-fixture-default-model-canary"; -test("sets, replaces and clears a harness's default model provider, and keeps its key out of the browser", async ({ page, request }) => { - // A fresh install: no harness has a default model provider yet. +test("sets, replaces and clears a harness's default model configuration, and keeps its key out of the browser", async ({ page, request }) => { + // A fresh install: no harness has a default model configuration yet. await openConsole(page, request, "system", { fresh: true }); - const section = page.getByRole("region", { name: "Default model provider" }); + const section = page.getByRole("region", { name: "Default model configuration" }); const codex = section.getByRole("article", { name: "Codex" }); const mcode = section.getByRole("article", { name: "MiniMax Code" }); await expect(codex).toContainText("Enabled"); @@ -18,8 +18,8 @@ test("sets, replaces and clears a harness's default model provider, and keeps it await expect(section.getByRole("article", { name: "Claude Code" })).toContainText("Not set"); await expect(mcode).toContainText("Disabled"); - await codex.getByRole("button", { name: "Set the default model provider for Codex" }).click(); - const set = page.getByRole("dialog", { name: "Set default model provider for Codex" }); + await codex.getByRole("button", { name: "Set the default model configuration for Codex" }).click(); + const set = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); await expect(set.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Responses"); await set.getByRole("combobox", { name: "Protocol", exact: true }).click(); await expect(page.getByRole("option")).toHaveText(["Anthropic Messages", "OpenAI Responses", "OpenAI Chat Completions"]); @@ -27,7 +27,8 @@ test("sets, replaces and clears a harness's default model provider, and keeps it await page.getByRole("option", { name: "OpenAI Chat Completions", exact: true }).click(); await set.getByLabel("Base URL").fill("https://model.example/v1"); await set.getByLabel("API key").fill(KEY); - await expect(set).toContainText("Your application specifies the model in the Agent’s model field."); + await set.getByLabel("Default model ID").fill("fixture-model"); + await expect(set).toContainText("Used for new Sessions when the application does not specify a model."); // Every rejected URL stays local; Enter cannot bypass validation or write a secret. for (const url of ["http://model.example/v1", "ftp://model.example", "https://user:password@model.example/v1", "https://@model.example/v1", "https:model.example/v1", "https://model.example/v1?token=x", "https://model.example/v1#fragment", "https://model.example/v1?", "https://model.example/v1#", "not a URL"]) { await set.getByLabel("Base URL").fill(url); @@ -37,6 +38,20 @@ test("sets, replaces and clears a harness's default model provider, and keeps it } expect(await writes(request)).toEqual([]); await set.getByLabel("Base URL").fill("https://model.example/v1"); + await expect(set.getByLabel("Harness configuration (JSON)")).toBeHidden(); + await set.getByText("Advanced settings", { exact: true }).click(); + const config = set.getByLabel("Harness configuration (JSON)"); + await config.fill("[]"); + await config.blur(); + await expect(set.getByText("Enter a valid JSON object, such as {}.")).toBeVisible(); + await expect(set.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await config.fill('{"model_reasoning_effort":"high"}'); + await set.getByRole("button", { name: "Format JSON" }).click(); + await expect(config).toHaveValue('{\n "model_reasoning_effort": "high"\n}'); + await expect(set.getByText("Advanced model settings require a native protocol for this harness. Change the protocol or clear the JSON object.")).toBeVisible(); + await expect(set.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await set.getByRole("combobox", { name: "Protocol", exact: true }).click(); + await page.getByRole("option", { name: "OpenAI Responses", exact: true }).click(); // Limits are Core's 32-bit whole numbers, and max output needs a context window at least as large. await set.getByLabel("Max output tokens").fill("32000"); await expect(set.getByText("Set a context window at least this large.")).toBeVisible(); @@ -51,13 +66,15 @@ test("sets, replaces and clears a harness's default model provider, and keeps it await set.getByLabel("Context window").fill("200000"); await set.getByRole("button", { name: "Save" }).click(); await expect(set).toBeHidden(); - for (const text of ["OpenAI Chat Completions", "https://model.example/v1", "Configured", "200,000", "32,000"]) await expect(codex).toContainText(text); + for (const text of ["OpenAI Responses", "https://model.example/v1", "Configured", "200,000", "32,000"]) await expect(codex).toContainText(text); expect(await page.content()).not.toContain(KEY); // Replacing starts from the saved fields but never from the key; closing the form forgets a typed key. - await codex.getByRole("button", { name: "Replace the default model provider for Codex" }).click(); - let replace = page.getByRole("dialog", { name: "Replace default model provider for Codex" }); - await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Chat Completions"); + await codex.getByRole("button", { name: "Replace the default model configuration for Codex" }).click(); + let replace = page.getByRole("dialog", { name: "Replace default model configuration for Codex" }); + await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Responses"); + await expect(replace.getByLabel("Default model ID")).toHaveValue("fixture-model"); + await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue(/model_reasoning_effort/); await expect(replace.getByLabel("Base URL")).toHaveValue("https://model.example/v1"); await expect(replace.getByLabel("API key")).toHaveValue(""); await expect(replace.getByRole("button", { name: "Save" })).toBeDisabled(); @@ -66,44 +83,46 @@ test("sets, replaces and clears a harness's default model provider, and keeps it await page.getByRole("option", { name: "Anthropic Messages", exact: true }).click(); await replace.getByRole("button", { name: "Cancel" }).click(); expect(await page.content()).not.toContain(KEY); - await codex.getByRole("button", { name: "Replace the default model provider for Codex" }).click(); - replace = page.getByRole("dialog", { name: "Replace default model provider for Codex" }); - await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Chat Completions"); + await codex.getByRole("button", { name: "Replace the default model configuration for Codex" }).click(); + replace = page.getByRole("dialog", { name: "Replace default model configuration for Codex" }); + await expect(replace.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("OpenAI Responses"); + await expect(replace.getByLabel("Default model ID")).toHaveValue("fixture-model"); + await expect(replace.getByLabel("Harness configuration (JSON)")).toHaveValue(/model_reasoning_effort/); await replace.getByRole("combobox", { name: "Protocol", exact: true }).click(); await page.getByRole("option", { name: "Anthropic Messages", exact: true }).click(); await expect(replace.getByLabel("API key")).toHaveValue(""); await replace.getByLabel("Base URL").fill("https://model.example/v2"); await replace.getByLabel("API key").fill(KEY); + await replace.getByLabel("Harness configuration (JSON)").fill("{}"); // Enter saves. await replace.getByLabel("API key").press("Enter"); await expect(replace).toBeHidden(); await expect(codex).toContainText("https://model.example/v2"); await expect(codex).toContainText("Anthropic Messages"); - await codex.getByRole("button", { name: "Clear the default model provider for Codex" }).click(); - const confirm = page.getByRole("dialog", { name: "Clear default model provider" }); - await confirm.getByRole("button", { name: "Clear default model provider" }).click(); + await codex.getByRole("button", { name: "Clear the default model configuration for Codex" }).click(); + const confirm = page.getByRole("dialog", { name: "Clear default model configuration" }); + await confirm.getByRole("button", { name: "Clear default model configuration" }).click(); await expect(confirm).toBeHidden(); await expect(codex).toContainText("Not set"); // MiniMax Code needs both limits; the form shows Core's reason. A disabled harness may still be configured. - await mcode.getByRole("button", { name: "Set the default model provider for MiniMax Code" }).click(); - const limits = page.getByRole("dialog", { name: "Set default model provider for MiniMax Code" }); + await mcode.getByRole("button", { name: "Set the default model configuration for MiniMax Code" }).click(); + const limits = page.getByRole("dialog", { name: "Set default model configuration for MiniMax Code" }); await expect(limits.getByRole("combobox", { name: "Protocol", exact: true })).toHaveText("Anthropic Messages"); await limits.getByRole("combobox", { name: "Protocol", exact: true }).click(); await page.getByRole("option", { name: "OpenAI Responses", exact: true }).click(); await limits.getByLabel("Base URL").fill("https://model.example/anthropic"); await limits.getByLabel("API key").fill(KEY); - await limits.getByRole("button", { name: "Save" }).click(); - await expect(limits.getByRole("alert")).toHaveText("selected harness requires positive model context_window and max_output_tokens"); + await limits.getByLabel("Default model ID").fill("fixture-model"); + await expect(limits.getByRole("button", { name: "Save" })).toBeDisabled(); await limits.getByRole("button", { name: "Cancel" }).click(); await expect(mcode).toContainText("Not set"); expect(await writes(request)).toEqual([ - "PUT /core/v1/harnesses/codex/model-provider", - "PUT /core/v1/harnesses/codex/model-provider", - "DELETE /core/v1/harnesses/codex/model-provider", - "PUT /core/v1/harnesses/mcode/model-provider", + "PUT /core/v1/harnesses/codex/model-configuration", + "PUT /core/v1/harnesses/codex/model-configuration", + "DELETE /core/v1/harnesses/codex/model-configuration", ]); const browserState = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage }, url: location.href, cookie: document.cookie })); expect(browserState).not.toContain(KEY); @@ -111,36 +130,38 @@ test("sets, replaces and clears a harness's default model provider, and keeps it test("reports a Core without a credential key as a configuration error, without rereading", async ({ page, request }) => { await openConsole(page, request, "system", { fresh: true, credentials: "none" }); - const codex = page.getByRole("region", { name: "Default model provider" }).getByRole("article", { name: "Codex" }); - await codex.getByRole("button", { name: "Set the default model provider for Codex" }).click(); - const set = page.getByRole("dialog", { name: "Set default model provider for Codex" }); + const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); + await codex.getByRole("button", { name: "Set the default model configuration for Codex" }).click(); + const set = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); await set.getByLabel("Base URL").fill("https://model.example/v1"); await set.getByLabel("API key").fill(KEY); + await set.getByLabel("Default model ID").fill("fixture-model"); const reads: string[] = []; page.on("request", (sent) => { if (sent.method() === "GET" && new URL(sent.url()).pathname === "/core/v1/harnesses") reads.push(sent.url()); }); await set.getByRole("button", { name: "Save" }).click(); await expect(set.getByRole("alert")).toHaveText("Core has no credential encryption key configured, so it can't store keys. Installer-based installs configure this automatically; for manual deployments, set OAC_CREDENTIAL_KEY_FILE for Core."); await expect(set.getByRole("button", { name: "Save" })).toBeEnabled(); expect(reads).toEqual([]); - expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-provider"]); + expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-configuration"]); await set.getByRole("button", { name: "Cancel" }).click(); await expect(codex).toContainText("Not set"); }); -test("reports an unconfirmed save, reads the default model providers again once and never repeats the write", async ({ page, request }) => { +test("reports an unconfirmed save, reads the default model configurations again once and never repeats the write", async ({ page, request }) => { await openConsole(page, request, "system", { fresh: true }); - const codex = page.getByRole("region", { name: "Default model provider" }).getByRole("article", { name: "Codex" }); - await codex.getByRole("button", { name: "Set the default model provider for Codex" }).click(); - const set = page.getByRole("dialog", { name: "Set default model provider for Codex" }); + const codex = page.getByRole("region", { name: "Default model configuration" }).getByRole("article", { name: "Codex" }); + await codex.getByRole("button", { name: "Set the default model configuration for Codex" }).click(); + const set = page.getByRole("dialog", { name: "Set default model configuration for Codex" }); await set.getByLabel("Base URL").fill("https://model.example/v1"); await set.getByLabel("API key").fill(KEY); - await failNext(request, { method: "PUT", path: "/harnesses/codex/model-provider", status: 500 }); + await set.getByLabel("Default model ID").fill("fixture-model"); + await failNext(request, { method: "PUT", path: "/harnesses/codex/model-configuration", status: 500 }); const reads: string[] = []; page.on("request", (sent) => { if (sent.method() === "GET" && new URL(sent.url()).pathname === "/core/v1/harnesses") reads.push(sent.url()); }); await set.getByRole("button", { name: "Save" }).click(); - await expect(set.getByRole("alert")).toHaveText("Core did not confirm the change. The default model providers were read again; check them before trying again."); + await expect(set.getByRole("alert")).toHaveText("Core did not confirm the change. The default model configurations were read again; check them before trying again."); await expect.poll(() => reads.length).toBe(1); await page.waitForTimeout(500); expect(reads).toHaveLength(1); - expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-provider"]); + expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-configuration"]); }); diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 84e2ed166..ba292316b 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -11,8 +11,8 @@ const deployment = (overrides: Partial = {}): SandboxDeployme }); const fleet = (value: SandboxDeployment, nodes = [node("n1")]): FleetState => ({ status: "ready", snapshot: { deployment: value, nodes, allocations: [], loadedAt: 0 }, targetGeneration: value.generation, refreshing: false, error: null }); const sandboxes = (state: FleetState) => gettingStartedSteps({ sandboxReset: false, fleet: state, localOnly: false, projects: [], sessions: 0, harnesses: [] }).sandboxes; -const provider = { object: "core.model_provider", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, last_used_at: null, last_error_code: null, last_error_at: null, updated_at: "2026-09-25T00:00:00Z" } as const; -const harness = (id: CoreHarness["id"], fields: Partial = {}): CoreHarness => ({ object: "core.harness", id, enabled: true, default: false, model_provider: null, ...fields }); +const provider = { object: "core.model_configuration", model: "fixture-model", harness_config: {}, model_provider: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true }, last_used_at: null, last_error_code: null, last_error_at: null, updated_at: "2026-09-25T00:00:00Z" } as const; +const harness = (id: CoreHarness["id"], fields: Partial = {}): CoreHarness => ({ object: "core.harness", model_configuration_support: { protocols: ["responses"], native_protocols: ["responses"], accepts_harness_config: true, token_limits_required: false }, id, enabled: true, default: false, model_configuration: null, ...fields }); describe("Getting started steps", () => { it("uses live provider readiness independently of target state or a durable pin", () => { @@ -29,7 +29,7 @@ describe("Getting started steps", () => { it("does not report ready during reset or an unreadable deployment, even with ready nodes", () => { for (const backend of ["docker", "e2b"] as const) { const input = { fleet: fleet(deployment({ provider: backend })), localOnly: false, projects: [project("p")], sessions: 1, - harnesses: [harness("codex", { default: true, model_provider: { ...provider, harness: "codex" } })] }; + harnesses: [harness("codex", { default: true, model_configuration: { ...provider, harness: "codex" } })] }; for (const sandboxReset of [true, "failed", undefined] as const) { const steps = gettingStartedSteps({ ...input, sandboxReset }); expect(steps.sandboxes).toMatchObject({ state: sandboxReset === true ? "todo" : sandboxReset === "failed" ? "unknown" : null, action: "nodes" }); @@ -47,7 +47,7 @@ describe("Getting started steps", () => { it("cannot complete onboarding while the installation read is pending or failed", () => { for (const localOnly of [undefined, "failed"] as const) { const steps = gettingStartedSteps({ sandboxReset: false, localOnly, fleet: fleet(deployment()), projects: [project("p")], sessions: 1, - harnesses: [harness("codex", { default: true, model_provider: { ...provider, harness: "codex" } })] }); + harnesses: [harness("codex", { default: true, model_configuration: { ...provider, harness: "codex" } })] }); expect(steps.sandboxes.state).toBe(localOnly === "failed" ? "unknown" : null); expect(checklistView([steps.sandboxes.state, steps.model, steps.key.state, steps.session.state], "open")).toBe("full"); } @@ -97,10 +97,10 @@ describe("Getting started steps", () => { it("needs a default model on the default harness, or on any enabled harness when none is default", () => { const model = (harnesses: Parameters[0]["harnesses"]) => gettingStartedSteps({ sandboxReset: false, fleet: { status: "loading" }, projects: undefined, sessions: null, harnesses }).model; const on = (id: CoreHarness["id"]) => ({ ...provider, harness: id }); - expect(model([harness("codex", { default: true }), harness("claude_sdk", { model_provider: on("claude_sdk") })])).toBe("todo"); - expect(model([harness("codex", { default: true, model_provider: on("codex") })])).toBe("done"); - expect(model([harness("codex"), harness("mcode", { enabled: false, model_provider: on("mcode") })])).toBe("todo"); - expect(model([harness("codex"), harness("claude_sdk", { model_provider: on("claude_sdk") })])).toBe("done"); + expect(model([harness("codex", { default: true }), harness("claude_sdk", { model_configuration: on("claude_sdk") })])).toBe("todo"); + expect(model([harness("codex", { default: true, model_configuration: on("codex") })])).toBe("done"); + expect(model([harness("codex"), harness("mcode", { enabled: false, model_configuration: on("mcode") })])).toBe("todo"); + expect(model([harness("codex"), harness("claude_sdk", { model_configuration: on("claude_sdk") })])).toBe("done"); expect(model(undefined)).toBeNull(); expect(model("failed")).toBe("unknown"); }); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 1d1758964..0fa930fb5 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -82,7 +82,7 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { export function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): StepState { if (!harnesses || harnesses === "failed") return harnesses ? "unknown" : null; const target = harnesses.find((harness) => harness.default); - const set = target ? target.model_provider !== null : harnesses.some((harness) => harness.enabled && harness.model_provider !== null); + const set = target ? target.model_configuration !== null : harnesses.some((harness) => harness.enabled && harness.model_configuration !== null); return set ? "done" : "todo"; } diff --git a/apps/web/src/features/system/DefaultModelsSection.tsx b/apps/web/src/features/system/DefaultModelsSection.tsx index 0238010d8..31cb3a130 100644 --- a/apps/web/src/features/system/DefaultModelsSection.tsx +++ b/apps/web/src/features/system/DefaultModelsSection.tsx @@ -1,65 +1,32 @@ -import { AgentCoreError, type CoreHarness, type CoreHarnessKind, type ModelProviderInput } from "@agents-core-web/agents-client"; +import { type CoreHarnessKind } from "@agents-core-web/agents-client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useId, useRef, useState, type ReactNode } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { ConsoleSelect } from "../../components/console-select"; import { ConfirmDialog } from "../../components/ConfirmDialog"; -import { EmptyState, HelpTip, revealInPageBody, Section } from "../../components/console-ui"; +import { EmptyState, revealInPageBody, Section } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; -import { Modal } from "../../components/Modal"; import { TableSkeleton } from "../../components/Skeleton"; import { failedLast, useFailureToast, useToast } from "../../components/Toast"; -import { coreError, coreFieldError } from "../../lib/core-error"; import { useDeleteFlow } from "../../lib/delete-flow"; -import { formatInteger } from "../../lib/format"; import { useConsoleIntent } from "../../lib/console-navigation"; -import { harnessNames, protocolNames } from "../../lib/harness-labels"; +import { harnessNames } from "../../lib/harness-labels"; import { admin } from "../../lib/projects"; +import { ModelProviderDialog } from "./ModelProviderDialog"; import { HarnessCard } from "./HarnessCard"; import { harnessesQuery } from "./harness-queries"; -/** A write that gets no answer in this time has an unknown outcome. */ -const WRITE_TIMEOUT_MS = 30_000; - -type Protocol = ModelProviderInput["protocol"]; -/** New providers start with the harness native protocol; saved providers keep their upstream protocol. */ -const defaultHarnessProtocol: Record = { codex: "responses", claude_sdk: "anthropic", mcode: "anthropic" }; -const protocolOptions = (["anthropic", "responses", "chat_completions"] as const).map((value) => ({ value, label: protocolNames[value] })); -/** Core stores both token limits as 32-bit integers. */ -const INT32_MAX = 2_147_483_647; - function message(error: unknown): string { return error instanceof Error ? error.message : String(error ?? ""); } -/** A token limit: empty is omitted; anything but a whole number within Core's range is a problem. */ -function tokenLimit(text: string): { value: number | undefined; problem: "whole" | "large" | null } { - const value = text.trim(); - if (!value) return { value: undefined, problem: null }; - if (!/^\d+$/u.test(value)) return { value: undefined, problem: "whole" }; - const number = Number(value); - return number > INT32_MAX ? { value: undefined, problem: "large" } : { value: number, problem: null }; -} - -function isProviderUrl(value: string): boolean { - try { - const url = new URL(value); - const authority = /^https:\/\/([^/]+)/iu.exec(value)?.[1]; - return authority !== undefined && url.protocol === "https:" && url.hostname !== "" && !authority.includes("@") - && !/[\\\s?#]/u.test(value); - } catch { - return false; - } -} - /** - * System › Default model provider: each harness's deployment default model provider. - * It applies to Core-hosted Sessions, after a provider in the request or on - * the Agent, and is the only source for Sessions without an environment; - * self-hosted Sessions bring their own. Whether a harness is enabled, and + * System owns each harness's deployment default model configuration. + * New Core-hosted Sessions and Sessions without an environment inherit it + * when no explicit Session or Agent model configuration takes precedence. + * Self-hosted Sessions bring their own configuration. Whether a harness is enabled, and * which is the default, is Core's startup configuration and only shown here. - * A write replaces the whole provider and needs the API key every time; the + * A write replaces the whole model configuration and needs the API key every time; the * key lives only in the open form's state, never in the query cache, storage * or the URL. Writes are never retried automatically; after each one the * harnesses are read again. @@ -86,7 +53,7 @@ export function DefaultModelsSection() { const editingHarness = (editing && harnesses?.find((harness) => harness.id === editing)) || null; const clear = useDeleteFlow( async (harness) => { - await admin.deleteHarnessModelProvider(harness, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + await admin.deleteHarnessModelConfiguration(harness, { signal: AbortSignal.timeout(30_000) }); toast.show(t("models.cleared", { harness: harnessNames[harness] }), { tone: "success" }); }, reread, @@ -145,159 +112,3 @@ export function DefaultModelsSection() { ); } - - - -/** - * Sets or replaces one harness's provider. Non-secret fields start from the - * current provider; the API key never does. The protocol describes the upstream - * model provider. The form checks the HTTPS provider URL and whole-number - * limits within Core's range, with max output no larger than the context window. - * Core's typed rejection is shown beside its field, or beside the form - * when no editable field applies. Enter saves; a save in flight blocks another. - */ -function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { - harness: CoreHarness | null; - onClose: () => void; - onSaved: (harness: CoreHarnessKind) => void; - onReread: () => void; -}) { - const { t } = useTranslation("system"); - const { t: tCommon } = useTranslation(); - const id = useId(); - const formId = `${id}-form`; - const current = harness?.model_provider ?? null; - const [protocol, setProtocol] = useState(current?.protocol ?? (harness ? defaultHarnessProtocol[harness.id] : "responses")); - const [baseUrl, setBaseUrl] = useState(current?.base_url ?? ""); - const [apiKey, setApiKey] = useState(""); - const [contextWindow, setContextWindow] = useState(current?.context_window === undefined ? "" : String(current.context_window)); - const [maxOutputTokens, setMaxOutputTokens] = useState(current?.max_output_tokens === undefined ? "" : String(current.max_output_tokens)); - const [busy, setBusy] = useState(false); - const saving = useRef(false); - const [error, setError] = useState(null); - const [rejection, setRejection] = useState(null); - const fieldError = (param: string) => coreFieldError(rejection, param, tCommon); - - const name = harness ? harnessNames[harness.id] : ""; - const limitsRequired = harness?.id === "mcode"; - const url = baseUrl.trim(); - const urlProblem = url && !isProviderUrl(url) ? t("models.form.baseUrlInvalid") : null; - const context = tokenLimit(contextWindow); - const output = tokenLimit(maxOutputTokens); - const limitProblem = (limit: ReturnType) => (limit.problem === "whole" ? t("models.form.wholeNumber") : limit.problem === "large" ? t("models.form.tooLarge") : null); - const outputProblem = limitProblem(output); - // The missing or undersized window is the field the administrator must fix. - const contextProblem = limitProblem(context) ?? (!outputProblem && output.value !== undefined && output.value > (context.value ?? 0) ? t("models.form.needsContext") : null); - const ready = harness !== null && !busy && url !== "" && !urlProblem && apiKey.trim() !== "" && !contextProblem && !outputProblem; - - async function save() { - if (!ready || !harness || saving.current) return; - saving.current = true; - setBusy(true); - setError(null); setRejection(null); - try { - await admin.setHarnessModelProvider(harness.id, { - protocol, base_url: url, api_key: apiKey.trim(), - ...(context.value === undefined ? {} : { context_window: context.value }), - ...(output.value === undefined ? {} : { max_output_tokens: output.value }), - }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); - onSaved(harness.id); - } catch (caught) { - // Never retried: a rejection shows Core's reason; an unknown outcome is read again first. - if (caught instanceof AgentCoreError && caught.status >= 400 && caught.status < 500 && caught.status !== 408) { - setRejection(caught); - setError(coreError(caught, tCommon)); - } else if (caught instanceof AgentCoreError && caught.code === "credential_storage_unavailable") { - // A deployment without a credential key stores nothing: a configuration error, not an unknown outcome. - setError(t("models.form.noCredentialKey")); - } else { - setError(t("models.form.uncertain")); - onReread(); - } - } finally { - saving.current = false; - setBusy(false); - } - } - - const limitField = (field: "context" | "output", value: string, setValue: (value: string) => void, problem: string | null) => { - const inputId = `${id}-${field}`; - problem = problem ?? fieldError(field === "context" ? "context_window" : "max_output_tokens"); - return ( -
- - - {t(`models.form.${field}Help${limitsRequired ? "Required" : ""}`)} - - { setValue(event.target.value); setRejection(null); setError(null); }} - aria-required={limitsRequired} - aria-invalid={problem ? true : undefined} - aria-describedby={`${inputId}-help${problem ? ` ${inputId}-problem` : ""}`} - /> - {problem ? {problem} : null} -
- ); - }; - - const baseUrlError = urlProblem ?? fieldError("base_url"); - const apiKeyError = fieldError("api_key"); - const fieldRejected = ["base_url", "api_key", "context_window", "max_output_tokens", "protocol"].some((param) => fieldError(param)); - return ( - { if (!busy) onClose(); }} - footer={( - <> - - - - )} - > -
{ event.preventDefault(); void save(); }}> -
- - {t("models.protocol")} - {t("models.form.protocolHelp")} {t("models.form.modelName")} - - { - const option = protocolOptions.find((option) => option.value === value); - if (option) setProtocol(option.value); - }} /> - {fieldError("protocol") ? {fieldError("protocol")} : null} -
-
- - { setBaseUrl(event.target.value); setRejection(null); setError(null); }} - autoComplete="off" - spellCheck={false} - aria-invalid={baseUrlError ? true : undefined} - aria-describedby={baseUrlError ? `${id}-url-problem` : undefined} - /> - {baseUrlError ? {baseUrlError} : null} -
-
- - - {t("models.form.apiKeyHelp")} - - { setApiKey(event.target.value); setRejection(null); setError(null); }} aria-required="true" aria-invalid={apiKeyError ? true : undefined} aria-describedby={`${id}-key-help${apiKeyError ? ` ${id}-key-problem` : ""}`} /> - {apiKeyError ? {apiKeyError} : null} -
-
- {limitField("context", contextWindow, setContextWindow, contextProblem)} - {limitField("output", maxOutputTokens, setMaxOutputTokens, outputProblem)} -
- {error && !fieldRejected ?

{error}

: null} -
-
- ); -} diff --git a/apps/web/src/features/system/HarnessCard.tsx b/apps/web/src/features/system/HarnessCard.tsx index 90f7391e5..70c493835 100644 --- a/apps/web/src/features/system/HarnessCard.tsx +++ b/apps/web/src/features/system/HarnessCard.tsx @@ -12,7 +12,8 @@ export function HarnessCard({ harness, busy, stale, onEdit, onClear }: { harness const locale = i18n.resolvedLanguage; const headingId = useId(); const name = harnessNames[harness.id]; - const provider = harness.model_provider; + const configuration = harness.model_configuration; + const provider = configuration?.model_provider; return (
@@ -36,18 +37,19 @@ export function HarnessCard({ harness, busy, stale, onEdit, onClear }: { harness {harness.default ? {t("models.default")} : null} - {provider ? <> + {provider && configuration ? <> + {configuration.model} {protocolNames[provider.protocol]} {provider.base_url} {provider.api_key_configured ? t("models.keyConfigured") : t("models.keyNotConfigured")} {provider.context_window !== undefined ? {formatInteger(provider.context_window, locale)} : null} {provider.max_output_tokens !== undefined ? {formatInteger(provider.max_output_tokens, locale)} : null} - {formatDateTime(Math.floor(Date.parse(provider.updated_at) / 1000), locale)} + {formatDateTime(Math.floor(Date.parse(configuration.updated_at) / 1000), locale)} : ( {t("models.notSet")} )} - {provider ? : null} + {configuration ? : null}
); } diff --git a/apps/web/src/features/system/ModelProviderDialog.tsx b/apps/web/src/features/system/ModelProviderDialog.tsx new file mode 100644 index 000000000..9b8ee1b7e --- /dev/null +++ b/apps/web/src/features/system/ModelProviderDialog.tsx @@ -0,0 +1,230 @@ +import { AgentCoreError, type CoreHarness, type CoreHarnessKind, type ModelProviderInput } from "@agents-core-web/agents-client"; +import { useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ConsoleSelect } from "../../components/console-select"; +import { HelpTip } from "../../components/console-ui"; +import { Modal } from "../../components/Modal"; +import { coreError, coreFieldError } from "../../lib/core-error"; +import { harnessNames, protocolNames } from "../../lib/harness-labels"; +import { admin } from "../../lib/projects"; + +/** A write that gets no answer in this time has an unknown outcome. */ +const WRITE_TIMEOUT_MS = 30_000; + +type Protocol = ModelProviderInput["protocol"]; +/** Core stores both token limits as 32-bit integers. */ +const INT32_MAX = 2_147_483_647; + +/** A token limit: empty is omitted; anything but a whole number within Core's range is a problem. */ +function tokenLimit(text: string): { value: number | undefined; problem: "whole" | "large" | null } { + const value = text.trim(); + if (!value) return { value: undefined, problem: null }; + if (!/^\d+$/u.test(value)) return { value: undefined, problem: "whole" }; + const number = Number(value); + return number > INT32_MAX ? { value: undefined, problem: "large" } : { value: number, problem: null }; +} + +function isProviderUrl(value: string): boolean { + try { + const url = new URL(value); + const authority = /^https:\/\/([^/]+)/iu.exec(value)?.[1]; + return authority !== undefined && url.protocol === "https:" && url.hostname !== "" && !authority.includes("@") + && !/[\\\s?#]/u.test(value); + } catch { + return false; + } +} + +/** + * Sets or replaces one harness's model configuration. Non-secret fields start from the + * current provider; the API key never does. The protocol describes the upstream + * model provider. The form checks the HTTPS provider URL and whole-number + * limits within Core's range, with max output no larger than the context window. + * Core's typed rejection is shown beside its field, or beside the form + * when no editable field applies. Enter saves; a save in flight blocks another. + */ +export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { + harness: CoreHarness | null; + onClose: () => void; + onSaved: (harness: CoreHarnessKind) => void; + onReread: () => void; +}) { + const { t } = useTranslation("system"); + const { t: tCommon } = useTranslation(); + const id = useId(); + const formId = `${id}-form`; + const configuration = harness?.model_configuration ?? null; + const current = configuration?.model_provider ?? null; + const support = harness?.model_configuration_support; + const limitsRequired = support?.token_limits_required ?? false; + const protocolOptions = (support?.protocols ?? []).map((value) => ({ value, label: protocolNames[value] })); + const [protocol, setProtocol] = useState(current?.protocol ?? support?.native_protocols[0] ?? "responses"); + const [baseUrl, setBaseUrl] = useState(current?.base_url ?? ""); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(configuration?.model ?? ""); + const [configText, setConfigText] = useState(JSON.stringify(configuration?.harness_config ?? {}, null, 2)); + const [configTouched, setConfigTouched] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(Boolean(configuration && (Object.keys(configuration.harness_config ?? {}).length || current?.context_window || current?.max_output_tokens)) || limitsRequired); + let nativeConfig: Record | null = null; + try { + const parsed: unknown = configText.trim() ? JSON.parse(configText) : {}; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) nativeConfig = parsed as Record; + } catch { /* Keep the draft intact; only an object can be saved. */ } + const [contextWindow, setContextWindow] = useState(current?.context_window === undefined ? "" : String(current.context_window)); + const [maxOutputTokens, setMaxOutputTokens] = useState(current?.max_output_tokens === undefined ? "" : String(current.max_output_tokens)); + const [busy, setBusy] = useState(false); + const saving = useRef(false); + const [error, setError] = useState(null); + const [rejection, setRejection] = useState(null); + const fieldError = (param: string) => coreFieldError(rejection, param, tCommon) ?? coreFieldError(rejection, `model_provider.${param}`, tCommon); + + const name = harness ? harnessNames[harness.id] : ""; + const configRouteProblem = nativeConfig !== null && Object.keys(nativeConfig).length > 0 && !support?.native_protocols.includes(protocol) ? t("models.form.configNativeOnly") : null; + const configTooLarge = nativeConfig !== null && new TextEncoder().encode(JSON.stringify(nativeConfig)).length > 16 * 1024; + const url = baseUrl.trim(); + const urlProblem = url && !isProviderUrl(url) ? t("models.form.baseUrlInvalid") : null; + const context = tokenLimit(contextWindow); + const output = tokenLimit(maxOutputTokens); + const limitProblem = (limit: ReturnType) => (limit.problem === "whole" ? t("models.form.wholeNumber") : limit.problem === "large" ? t("models.form.tooLarge") : null); + const outputProblem = limitProblem(output); + // The missing or undersized window is the field the administrator must fix. + const contextProblem = limitProblem(context) ?? (!outputProblem && output.value !== undefined && output.value > (context.value ?? 0) ? t("models.form.needsContext") : null); + const ready = harness !== null && !busy && url !== "" && !urlProblem && apiKey.trim() !== "" && model.trim() !== "" && nativeConfig !== null && !configTooLarge && !configRouteProblem && (!limitsRequired || Boolean(context.value && output.value)) && !contextProblem && !outputProblem; + + async function save() { + if (!ready || !harness || !nativeConfig || saving.current) return; + saving.current = true; + setBusy(true); + setError(null); setRejection(null); + try { + await admin.setHarnessModelConfiguration(harness.id, { + model: model.trim(), harness_config: nativeConfig, + model_provider: { protocol, base_url: url, api_key: apiKey.trim(), + ...(context.value === undefined ? {} : { context_window: context.value }), + ...(output.value === undefined ? {} : { max_output_tokens: output.value }), + }, + }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + onSaved(harness.id); + } catch (caught) { + // Never retried: a rejection shows Core's reason; an unknown outcome is read again first. + if (caught instanceof AgentCoreError && caught.status >= 400 && caught.status < 500 && caught.status !== 408) { + setRejection(caught); + if (caught.param === "harness_config" || ["context_window", "max_output_tokens", "model_provider.context_window", "model_provider.max_output_tokens"].includes(caught.param ?? "")) setAdvancedOpen(true); + setError(coreError(caught, tCommon)); + } else if (caught instanceof AgentCoreError && caught.code === "credential_storage_unavailable") { + // A deployment without a credential key stores nothing: a configuration error, not an unknown outcome. + setError(t("models.form.noCredentialKey")); + } else { + setError(t("models.form.uncertain")); + onReread(); + } + } finally { + saving.current = false; + setBusy(false); + } + } + + const limitField = (field: "context" | "output", value: string, setValue: (value: string) => void, problem: string | null) => { + const inputId = `${id}-${field}`; + problem = problem ?? fieldError(field === "context" ? "context_window" : "max_output_tokens"); + return ( +
+ + + {t(`models.form.${field}Help${limitsRequired ? "Required" : ""}`)} + + { setValue(event.target.value); setRejection(null); setError(null); }} + aria-required={limitsRequired} + aria-invalid={problem ? true : undefined} + aria-describedby={`${inputId}-help${problem ? ` ${inputId}-problem` : ""}`} + /> + {problem ? {problem} : null} +
+ ); + }; + + const baseUrlError = urlProblem ?? fieldError("base_url"); + const apiKeyError = fieldError("api_key"); + const configError = configRouteProblem ?? (configTouched && configTooLarge ? t("models.form.configTooLarge") : configTouched && nativeConfig === null ? t("models.form.configInvalid") : null) ?? fieldError("harness_config"); + const modelError = fieldError("model"); + const fieldRejected = ["base_url", "api_key", "context_window", "max_output_tokens", "protocol", "model", "harness_config"].some((param) => fieldError(param)); + return ( + { if (!busy) onClose(); }} + footer={( + <> + + + + )} + > +
{ event.preventDefault(); void save(); }}> +
+ + {t("models.protocol")} + {t("models.form.protocolHelp")} + + { + const option = protocolOptions.find((option) => option.value === value); + if (option) { + setProtocol(option.value); setRejection(null); setError(null); + if (nativeConfig && Object.keys(nativeConfig).length && !support?.native_protocols.includes(option.value)) setAdvancedOpen(true); + } + }} /> + {fieldError("protocol") ? {fieldError("protocol")} : null} +
+
+ + { setBaseUrl(event.target.value); setRejection(null); setError(null); }} + autoComplete="off" + spellCheck={false} + aria-invalid={baseUrlError ? true : undefined} + aria-describedby={baseUrlError ? `${id}-url-problem` : undefined} + /> + {baseUrlError ? {baseUrlError} : null} +
+
+ + + {t("models.form.apiKeyHelp")} + + { setApiKey(event.target.value); setRejection(null); setError(null); }} aria-required="true" aria-invalid={apiKeyError ? true : undefined} aria-describedby={`${id}-key-help${apiKeyError ? ` ${id}-key-problem` : ""}`} /> + {apiKeyError ? {apiKeyError} : null} +
+
+ {t("models.form.modelName")} + { setModel(event.target.value); setRejection(null); setError(null); }} /> + {modelError ? {modelError} : null} +
+
setAdvancedOpen(event.currentTarget.open)}> + {t("models.form.advanced")} +
+ {support?.accepts_harness_config ?
+ {t("models.form.configHelp", { harness: name })} +