Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/daemon/internal/agent/claudesdk/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) {
config := workspaceFixture(t)
original := slices.Clone(config.Env)
req := workspaceRequest()
req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/anthropic", "api_key": "selected-secret"}
req.AgentOptions["model_provider"] = map[string]any{"protocol": "anthropic", "base_url": "https://provider.example/anthropic", "api_key": "selected-secret", "context_window": 64000, "max_output_tokens": 4096}
start, env, err := prepare(config, req)
if err != nil {
t.Fatal(err)
Expand All @@ -52,6 +52,9 @@ func TestWorkspaceProviderCredentialsReplaceAmbientSelection(t *testing.T) {
if !slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-secret") || slices.Contains(env, "ANTHROPIC_AUTH_TOKEN=selected-provider-fixture") {
t.Fatal("provider selection was not exclusive")
}
if !slices.Contains(env, "CLAUDE_CODE_MAX_CONTEXT_TOKENS=64000") || !slices.Contains(env, "CLAUDE_CODE_MAX_OUTPUT_TOKENS=4096") {
t.Fatal("token limits were not native")
}
for _, value := range []any{nil, "secret", map[string]any{"protocol": "anthropic", "base_url": "http://provider.example", "api_key": "secret"}, map[string]any{"protocol": "anthropic", "base_url": "https://user:pass@provider.example", "api_key": "secret"}} {
req.AgentOptions["model_provider"] = value
if _, _, err := prepare(config, req); err == nil || strings.Contains(err.Error(), "secret") {
Expand Down
13 changes: 11 additions & 2 deletions apps/daemon/internal/agent/claudesdk/provider.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package claudesdk

import (
"strconv"
"strings"

harnessconfiguration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk"
Expand All @@ -12,7 +13,15 @@ func providerEnvironment(value any) ([]string, error) {
if err != nil {
return nil, err
}
return []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey}, nil
env := []string{"ANTHROPIC_BASE_URL=" + provider.BaseURL, "ANTHROPIC_AUTH_TOKEN=" + provider.APIKey}
// Native uses the context window only for a model it does not know.
if provider.ContextWindow > 0 {
env = append(env, "CLAUDE_CODE_MAX_CONTEXT_TOKENS="+strconv.Itoa(int(provider.ContextWindow)))
}
if provider.MaxOutputTokens > 0 {
env = append(env, "CLAUDE_CODE_MAX_OUTPUT_TOKENS="+strconv.Itoa(int(provider.MaxOutputTokens)))
}
return env, nil
}

func withProvider(env, provider []string) []string {
Expand All @@ -22,7 +31,7 @@ func withProvider(env, provider []string) []string {
out := make([]string, 0, len(env)+len(provider))
for _, entry := range env {
key, _, _ := strings.Cut(entry, "=")
if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" {
if key != "ANTHROPIC_API_KEY" && key != "ANTHROPIC_AUTH_TOKEN" && key != "ANTHROPIC_BASE_URL" && key != "CLAUDE_CODE_MAX_CONTEXT_TOKENS" && key != "CLAUDE_CODE_MAX_OUTPUT_TOKENS" {
out = append(out, entry)
}
}
Expand Down
6 changes: 4 additions & 2 deletions apps/daemon/internal/agent/claudesdk/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"

"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace"
Expand Down Expand Up @@ -102,7 +103,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) {
env := []string{}
for _, entry := range os.Environ() {
name, _, _ := strings.Cut(entry, "=")
if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" {
if !slices.Contains([]string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_MAX_OUTPUT_TOKENS", "CLAUDE_CONFIG_DIR", "HOME", "TMPDIR"}, name) {
env = append(env, entry)
}
}
Expand All @@ -124,7 +125,8 @@ func workspaceEnvName(name string) bool {
switch name {
case "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
"ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL",
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY":
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY":
return true
default:
return false
Expand Down
15 changes: 15 additions & 0 deletions apps/daemon/internal/agent/codex/model_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package codex

import (
"reflect"
"slices"
"strings"
"testing"
)
Expand Down Expand Up @@ -35,3 +36,17 @@ func TestPlanRejectsIncompleteExplicitProvider(t *testing.T) {
}
}
}

func TestPlanMapsTheContextWindowNatively(t *testing.T) {
t.Setenv("HOME", t.TempDir())
provider := map[string]any{"protocol": "responses", "base_url": "https://model.example/v1", "api_key": "fixture", "context_window": 64000}
plan, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model": "m", "model_provider": provider}, nil)
if err != nil || !slices.Contains(plan.ExtraConfig, [2]string{"model_context_window", "64000"}) {
t.Fatalf("context window not native: %v %v", plan.ExtraConfig, err)
}
plan.Cleanup()
provider["max_output_tokens"] = 4096
if _, err := BuildSessionPlan(testStateRoot(t), "limits", map[string]any{"model": "m", "model_provider": provider}, nil); err == nil {
t.Fatal("max_output_tokens without a native home accepted")
}
}
5 changes: 4 additions & 1 deletion apps/daemon/internal/agent/codex/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ func BuildSessionPlan(stateRoot, agentStateKey string, opts map[string]any, cont
plan.DisableFeatures = append(plan.DisableFeatures, "shell_snapshot")
env = append(env, providerAPIKeyEnv+"="+provider.BearerToken)
excludeFromShellEnvironment(&plan, providerAPIKeyEnv)
if provider.ContextWindow > 0 {
plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"model_context_window", fmt.Sprint(provider.ContextWindow)})
}
}

plan.Env = env
Expand Down Expand Up @@ -258,7 +261,7 @@ func normaliseProviderConfig(raw any) (providerConfig, bool, error) {
if err != nil {
return providerConfig{}, false, err
}
return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses"}, true, nil
return providerConfig{BaseURL: provider.BaseURL, BearerToken: provider.APIKey, WireAPI: "responses", ContextWindow: provider.ContextWindow}, true, nil
}

func stringOpt(opts map[string]any, key string) string {
Expand Down
2 changes: 2 additions & 0 deletions apps/daemon/internal/agent/codex/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type providerConfig struct {
// WireAPI is "responses" (codex Responses API) or "chat" (rejected
// upstream since codex-rs 0.140). Empty defaults to "responses".
WireAPI string
// ContextWindow is the native model_context_window when set.
ContextWindow int32
}

// writeCodexProviderConfig writes (or appends to) <codexHome>/config.toml
Expand Down
4 changes: 2 additions & 2 deletions apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ function providerProblem(harness, input) {
if (!httpsBase(input.base_url)) return "model provider requires an HTTPS base_url without credentials, query or fragment";
if (!["anthropic", "responses", "chat_completions"].includes(input.protocol)) return "unsupported model provider protocol";
if (!input.api_key.trim() || Buffer.byteLength(input.api_key) > 16384 || /[\0\r\n]/.test(input.api_key)) return "invalid model provider API key";
if ((input.max_output_tokens ?? 0) > (input.context_window ?? 0)) return "invalid model token limits";
if (input.context_window && (input.max_output_tokens ?? 0) > input.context_window) return "invalid model token limits";
return null;
}

Expand All @@ -553,7 +553,7 @@ 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 support = (id) => ({ protocols: id === "codex" ? ["responses"] : id === "claude_sdk" ? ["anthropic"] : ["anthropic", "responses", "chat_completions"], accepts_harness_config: id === "claude_sdk" });
const support = (id) => ({ protocols: id === "codex" ? ["responses"] : id === "claude_sdk" ? ["anthropic"] : ["anthropic", "responses", "chat_completions"], accepts_harness_config: id === "claude_sdk", accepts_max_output_tokens: id !== "codex" });
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);
Expand Down
21 changes: 12 additions & 9 deletions apps/web/e2e/system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,15 @@ test("sets, replaces and clears a harness's default model configuration, and kee
// Codex accepts no native Harness configuration.
await set.getByText("Advanced settings", { exact: true }).click();
await expect(set.getByLabel("Harness configuration (JSON)")).toHaveCount(0);
// Limits are Core's 32-bit whole numbers, and max output fits a set context window.
await set.getByLabel("Max output tokens").fill("32000");
await expect(set.getByLabel("Context window", { exact: true })).not.toHaveAttribute("aria-invalid", "true");
await set.getByLabel("Context window", { exact: true }).fill("1000");
await expect(set.getByText("Set a context window at least this large.")).toBeVisible();
await expect(set.getByLabel("Context window", { exact: true })).toHaveAttribute("aria-invalid", "true");
await expect(set.getByLabel("Context window", { exact: true })).toHaveAccessibleDescription(/Set a context window at least this large/);
await expect(set.getByLabel("Max output tokens", { exact: true })).not.toHaveAttribute("aria-invalid", "true");
// Codex has no native output limit; limits are Core's 32-bit whole numbers.
await expect(set.getByLabel("Max output tokens")).toHaveCount(0);
await set.getByLabel("Context window").fill("2147483648");
await expect(set.getByText("Enter a whole number up to 2147483647.")).toBeVisible();
await expect(set.getByRole("button", { name: "Save" })).toBeDisabled();
await set.getByLabel("Context window").fill("200000");
await set.getByRole("button", { name: "Save" }).click();
await expect(set).toBeHidden();
for (const text of ["Ready", "OpenAI Responses", "https://model.example/v1", "Configured", "200,000", "32,000"]) await expect(codex).toContainText(text);
for (const text of ["Ready", "OpenAI Responses", "https://model.example/v1", "Configured", "200,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.
Expand Down Expand Up @@ -131,6 +125,15 @@ test("sets, replaces and clears a harness's default model configuration, and kee
await limits.getByLabel("Base URL").fill("https://model.example/anthropic");
await limits.getByLabel("API key").fill(KEY);
await limits.getByLabel("Default model ID").fill("fixture-model");
// Max output alone is valid and must fit a set context window.
await limits.getByText("Advanced settings", { exact: true }).click();
await limits.getByLabel("Max output tokens").fill("32000");
await expect(limits.getByLabel("Context window", { exact: true })).not.toHaveAttribute("aria-invalid", "true");
await limits.getByLabel("Context window", { exact: true }).fill("1000");
await expect(limits.getByText("Set a context window at least this large.")).toBeVisible();
await expect(limits.getByLabel("Context window", { exact: true })).toHaveAccessibleDescription(/Set a context window at least this large/);
await expect(limits.getByLabel("Max output tokens", { exact: true })).not.toHaveAttribute("aria-invalid", "true");
await limits.getByLabel("Context window", { exact: true }).fill("");
await expect(limits.getByRole("button", { name: "Save" })).toBeEnabled();
await limits.getByRole("button", { name: "Cancel" }).click();
await expect(mcode).toContainText("Not set");
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/overview/getting-started.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const deployment = (overrides: Partial<SandboxDeployment> = {}): 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_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> = {}): CoreHarness => ({ object: "core.harness", model_configuration_support: { protocols: ["responses"], accepts_harness_config: true }, id, enabled: true, default: false, model_configuration: null, ...fields });
const harness = (id: CoreHarness["id"], fields: Partial<CoreHarness> = {}): CoreHarness => ({ object: "core.harness", model_configuration_support: { protocols: ["responses"], accepts_harness_config: true, accepts_max_output_tokens: 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", () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/system/ModelProviderDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: {
const url = baseUrl.trim();
const urlProblem = url && !isProviderUrl(url) ? t("models.form.baseUrlInvalid") : null;
const context = tokenLimit(contextWindow);
const output = tokenLimit(maxOutputTokens);
const output = tokenLimit(support?.accepts_max_output_tokens ? maxOutputTokens : "");
const limitProblem = (limit: ReturnType<typeof tokenLimit>) => (limit.problem === "whole" ? t("models.form.wholeNumber") : limit.problem === "large" ? t("models.form.tooLarge") : null);
const outputProblem = limitProblem(output);
// An undersized window is the field the administrator must fix.
Expand Down Expand Up @@ -220,7 +220,7 @@ export function ModelProviderDialog({ harness, onClose, onSaved, onReread }: {
</div> : null}
<div className="system-model-limits">
{limitField("context", contextWindow, setContextWindow, contextProblem)}
{limitField("output", maxOutputTokens, setMaxOutputTokens, outputProblem)}
{support?.accepts_max_output_tokens ? limitField("output", maxOutputTokens, setMaxOutputTokens, outputProblem) : null}
</div>
</div>
</details>
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/core-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf
| `model_provider_base_url_invalid` | `base_url` | omitted | Requires HTTPS without credentials, query or fragment |
| `model_provider_protocol_unsupported` | `protocol` | `harness` and `allowed_protocols`, from the build's adapter catalog | The protocol is unknown or unsupported by the selected Harness |
| `model_provider_api_key_invalid` | `api_key` | `max_length`: 16384 | The key is empty, too long or contains a prohibited character |
| `model_provider_token_limits_invalid` | `context_window` or `max_output_tokens` | omitted | A limit is negative, or `max_output_tokens` exceeds a set `context_window` |
| `model_provider_token_limits_invalid` | `context_window` or `max_output_tokens` | omitted | A limit is negative, `max_output_tokens` exceeds a set `context_window`, or the Harness has no native setting for a set limit |
| `model_configuration_model_invalid` | `model` | omitted | The deployment default's model is not a nonempty model identifier |
| `harness_config_invalid` | `harness_config` | omitted | The deployment default's native parameters are unsupported or invalid |
| `invalid_sandbox_configuration` | `resources.cpus` | `min`: 1, `max`: 255 | The CPU count is outside the supported bounds |
Expand Down
4 changes: 4 additions & 0 deletions contracts/agents-api/core.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2537,12 +2537,16 @@ definitions:
properties:
accepts_harness_config:
type: boolean
accepts_max_output_tokens:
description: AcceptsMaxOutputTokens reports that the Harness maps max_output_tokens natively.
type: boolean
protocols:
items:
type: string
type: array
required:
- accepts_harness_config
- accepts_max_output_tokens
- protocols
type: object
v1.ModelProviderInput:
Expand Down
16 changes: 9 additions & 7 deletions contracts/agents-api/model-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ A Harness receives one frozen model provider bundle, defined by [`modelprovider.
| `api_key` | Nonempty, at most 16 KiB, without NUL, CR or LF |
| `context_window`, `max_output_tokens` | Optional nonnegative integers, with output no larger than context. Use the real model's limits |

Supported protocols by Harness:
Supported protocols and native token limits by Harness:

| Harness | Supported protocols, default first |
| --- | --- |
| Codex | `responses` |
| Claude Code | `anthropic` |
| MiniMax Code | `anthropic`, `responses`, `chat_completions` |
| Harness | Supported protocols, default first | Native token limits |
| --- | --- | --- |
| Codex | `responses` | `model_context_window`; no output limit |
| Claude Code | `anthropic` | `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, which native uses only for a model it does not know, and `CLAUDE_CODE_MAX_OUTPUT_TOKENS`, which native caps at the model's limit |
| MiniMax Code | `anthropic`, `responses`, `chat_completions` | The custom model's `limit.context` and `limit.output` |

A set limit with no native setting returns `model_provider_token_limits_invalid`.

Core validates the resolved combination before writing the Session. Core and Runtime read the same ordered `protocols` declaration in `internal/harnessconfig`. There is no model API proxy, passthrough gateway or cross-protocol conversion, including inside a Harness. Unsupported saved configurations and Session snapshots fail when used; they are never rewritten, aliased or migrated.

Expand Down Expand Up @@ -132,7 +134,7 @@ PUT requires `model` and a complete `model_provider`; `harness_config` defaults

Session creation decrypts the default of the resolved Harness and freezes it in the Session's encrypted snapshot like any other bundle, so changing or removing the default never reaches existing Sessions. The execution-configuration read shows the frozen safe view with source `deployment`. A missing or invalid provider snapshot fails closed, with no fallback to another model or provider.

`model_configuration_support` derives from the adapter declaration that Core and Runtime share: `protocols` lists the selectable native protocols with the default first, and `accepts_harness_config` says whether native parameters are accepted. It describes the build, not a live Runtime or a remote model.
`model_configuration_support` derives from the adapter declaration that Core and Runtime share: `protocols` lists the selectable native protocols with the default first, `accepts_harness_config` says whether native parameters are accepted, and `accepts_max_output_tokens` whether `max_output_tokens` has a native setting. It describes the build, not a live Runtime or a remote model.

### Deployment default observations

Expand Down
2 changes: 2 additions & 0 deletions contracts/agents-api/v1/model_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ func ValidateNativeModelConfiguration(harness, model string, raw json.RawMessage
type ModelConfigurationSupport struct {
Protocols []string `json:"protocols" binding:"required"`
AcceptsHarnessConfig bool `json:"accepts_harness_config" binding:"required"`
// AcceptsMaxOutputTokens reports that the Harness maps max_output_tokens natively.
AcceptsMaxOutputTokens bool `json:"accepts_max_output_tokens" binding:"required"`
}

func (p *ModelProviderInput) ValidateConfiguration(harness string, native json.RawMessage) error {
Expand Down
3 changes: 3 additions & 0 deletions contracts/agents-api/v1/model_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,8 @@ func (p *ModelProviderInput) ValidateHarnessWithRegistry(harness string, registr
if err := configuration.ValidateProtocol(string(p.Protocol)); err != nil {
return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: err.Error()}
}
if err := configuration.ValidateMaxOutputTokens(p.MaxOutputTokens); err != nil {
return &ModelProviderError{Code: "model_provider_token_limits_invalid", Param: "max_output_tokens", message: err.Error()}
}
return nil
}
Loading
Loading