diff --git a/apps/daemon/internal/agent/codex/provider_config.go b/apps/daemon/internal/agent/codex/provider_config.go index 256feed40..ed8ab4b10 100644 --- a/apps/daemon/internal/agent/codex/provider_config.go +++ b/apps/daemon/internal/agent/codex/provider_config.go @@ -24,11 +24,11 @@ type providerConfig struct { // Name is the human-readable label shown in codex UI; defaults to // "OpenAgentCore" when empty. Name string - // BaseURL is the model provider's HTTPS endpoint, including any - // path prefix (e.g. /v1). Required. + // BaseURL is the validated provider's base URL, including any path + // prefix (e.g. /v1). BaseURL string // BearerToken is the literal API key. Codex's `experimental_bearer_token` - // field accepts a string; we emit it verbatim. Required. + // field accepts a string; we emit it verbatim. BearerToken string // HTTPHeaders is forwarded as `[model_providers.oac.http_headers]`. // Keys / values rendered as TOML basic strings. @@ -57,13 +57,6 @@ type providerConfig struct { // The provider block is rewritten on every prompt; manual edits to // scratch CODEX_HOME files are lost on the next spawn. func writeCodexProviderConfig(codexHome string, cfg providerConfig) error { - if strings.TrimSpace(cfg.BaseURL) == "" { - return fmt.Errorf("codex: provider base_url is required") - } - if strings.TrimSpace(cfg.BearerToken) == "" { - return fmt.Errorf("codex: provider bearer_token is required") - } - var b strings.Builder b.WriteString("# Generated by oac-daemon (codex agent) — do not edit by hand.\n") b.WriteString("# Rewritten on every prompt; manual changes will be lost.\n\n") diff --git a/apps/daemon/internal/agent/codex/provider_config_test.go b/apps/daemon/internal/agent/codex/provider_config_test.go index 650db06bd..a7194c7d4 100644 --- a/apps/daemon/internal/agent/codex/provider_config_test.go +++ b/apps/daemon/internal/agent/codex/provider_config_test.go @@ -92,24 +92,6 @@ func TestWriteCodexProviderConfig_FullProvider(t *testing.T) { } } -func TestWriteCodexProviderConfig_RejectsMissingFields(t *testing.T) { - dir := t.TempDir() - cases := []struct { - name string - cfg providerConfig - }{ - {"missing base_url", providerConfig{BearerToken: "sk-x"}}, - {"missing bearer_token", providerConfig{BaseURL: "https://x"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := writeCodexProviderConfig(dir, tc.cfg); err == nil { - t.Fatal("expected error for incomplete provider config") - } - }) - } -} - // TestWriteCodexProviderConfig_AppendsAlongsideMCP verifies the two // writers coexist on the same file. Without append semantics one would // silently overwrite the other depending on call order. diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index 5dec68997..df8363535 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -122,7 +122,7 @@ func admit(cfg Config, roots *x509.CertPool, req proto.PromptRequestPayload, env return nil, unsupported("a Session without a frozen model provider") } provider := *req.ModelProvider - if err := provider.Validate(); err != nil { + if err := provider.Validate(false); err != nil { return nil, invalidSession("model provider: %v", err) } bindings, err := agent.ResolveMCPBindings(req) diff --git a/apps/daemon/internal/gateway/model.go b/apps/daemon/internal/gateway/model.go index 4ec28835b..37229acc5 100644 --- a/apps/daemon/internal/gateway/model.go +++ b/apps/daemon/internal/gateway/model.go @@ -23,7 +23,7 @@ type modelRelay struct { } func newModelRelay(p modelprovider.Provider, t http.RoundTripper) (*modelRelay, error) { - if err := p.Validate(); err != nil { + if err := p.Validate(false); err != nil { return nil, err } credential, err := modelprovider.UpstreamCredential(p.Protocol) diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md index 3873dcdf5..bffa4481a 100644 --- a/contracts/agents-api/core-errors.md +++ b/contracts/agents-api/core-errors.md @@ -58,9 +58,9 @@ Each code returns HTTP 400 with `type: "invalid_request_error"`. A missing, malf | `invalid_name` | `name` | `max_length`: 128 for Projects and nodes, 80 for Project keys | The name failed the resource's validator | | `invalid_node_capacity` | `max_active` or `max_retained` | `min`: 1, `max`: 1000000 | Capacity is invalid; retained capacity must also be at least active capacity | | `invalid_model_provider` | null | omitted | A complete model-provider bundle is required | -| `model_provider_base_url_invalid` | `base_url` | omitted | Requires HTTPS without credentials, query or fragment | +| `model_provider_base_url_invalid` | `base_url` | omitted | The base URL breaks the [provider rule](./model-execution.md#session-override) | | `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_api_key_invalid` | `api_key` | `max_length`: 16384 | The key breaks the [provider rule](./model-execution.md#session-override) | | `model_provider_token_limits_invalid` | `context_window` or `max_output_tokens` | omitted | Limits are invalid, or the Harness requires positive limits that are missing | | `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 | diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index d65e6e9a0..8f638c513 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -2,7 +2,7 @@ title: "Model execution" --- -Each Session runs one Harness with one model provider. Core selects them through three Core extensions that the pinned upstream protocol does not define: `x_agents_core.harness` chooses the Harness, `x_agents_core.model_provider` supplies the endpoint and key, and `x_agents_core.harness_config` carries native model parameters. Core has no provider catalog, model alias resolution or product permission model; besides Session and saved-Agent bundles, the only stored bundle is one [deployment default](#deployment-defaults) per Harness. This document is the Harness–model provider protocol: [`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) validates the frozen provider connection and declares what the [credential gateway](#credential-gateway) relays, and each Harness declares its protocols and native parameters through [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go). +Each Session runs one Harness with one model provider. Core selects them through three Core extensions that the pinned upstream protocol does not define: `x_agents_core.harness` chooses the Harness, `x_agents_core.model_provider` supplies the endpoint and key, and `x_agents_core.harness_config` carries native model parameters. Core has no provider catalog, model alias resolution or product permission model; besides Session and saved-Agent bundles, the only stored bundle is one [deployment default](#deployment-defaults) per Harness. This document is the Harness–model provider protocol: [`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) defines the [provider rule](#session-override) that Core and the Runtime both apply and declares what the [credential gateway](#credential-gateway) relays, and each Harness declares its protocols and native parameters through [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go). ## Harness selection @@ -72,7 +72,7 @@ Every creation request records caller intent before resolving saved Agents, temp ``` - `protocol` names the upstream API (`anthropic`, `responses` or `chat_completions`), not an engine. The selected Harness must support it natively. -- `base_url` uses HTTPS with a valid host, without credentials, query or fragment. A loopback host means the agent host's network namespace, for every Environment type. For `anthropic` it excludes the version path, because the Harness appends `/v1/messages`, so a value ending in `/v1` or `/v1/` is rejected. +- `base_url` uses HTTPS with a valid host, without credentials, query or fragment. A loopback host means the agent host's network namespace, for every Environment type. Core rejects plain `http`; the Runtime accepts it only to a loopback IP address, for the credential gateway's listener that it hands the Harness. For `anthropic` it excludes the version path, because the Harness appends `/v1/messages`, so a value ending in `/v1` or `/v1/` is rejected. - `api_key` is nonempty, at most 16 KiB and contains no NUL, CR or LF. - `context_window` and `max_output_tokens` are optional nonnegative integers, with output no larger than context; both must be positive for MiniMax Code. Use the real model's limits. - `agent.model` is the exact provider model ID; a supplied value always replaces the deployment model. diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 9d52bcb28..e42ba2e2f 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -2,10 +2,8 @@ package v1 import ( "encoding/json" - "net/url" - "strings" + "errors" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) @@ -19,6 +17,22 @@ type ModelProviderError struct { func (e *ModelProviderError) Error() string { return e.message } +// modelProviderErrorCodes maps each modelprovider.FieldError field to its code. +var modelProviderErrorCodes = map[string]string{ + "base_url": "model_provider_base_url_invalid", + "protocol": "model_provider_protocol_unsupported", + "api_key": "model_provider_api_key_invalid", + "context_window": "model_provider_token_limits_invalid", + "max_output_tokens": "model_provider_token_limits_invalid", +} + +// Model provider sources, as recorded in a Session's execution configuration. +const ( + ModelProviderSourceSession = "session" + ModelProviderSourceAgent = "agent" + ModelProviderSourceDeployment = "deployment" +) + // SessionExecutionInput is a write-only execution extension, not a provider resource. type SessionExecutionInput struct { // Environment supplies placement-independent preparation through the Core extension. @@ -35,47 +49,33 @@ type ModelProviderInput struct { MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` } -func (p *ModelProviderInput) Validate() error { - return p.validate(builtin.Registry()) +// Provider is the bundle as the Harness–Model provider protocol carries it. +func (p *ModelProviderInput) Provider() modelprovider.Provider { + return modelprovider.Provider{Protocol: modelprovider.Protocol(p.Protocol), BaseURL: p.BaseURL, APIKey: p.APIKey, + ContextWindow: p.ContextWindow, MaxOutputTokens: p.MaxOutputTokens} } -func (p *ModelProviderInput) validate(registry harnessconfig.Registry) error { +// Validate applies modelprovider's rule, without loopback http, and reports +// the rejected field with its public code. +func (p *ModelProviderInput) Validate() error { if p == nil { - return &ModelProviderError{Code: "invalid_model_provider", Param: "", message: "model_provider is required"} - } - if !validModelProviderBaseURL(p.BaseURL) { - return &ModelProviderError{Code: "model_provider_base_url_invalid", Param: "base_url", message: "model provider requires an HTTPS base_url without credentials, query or fragment"} - } - if base, _ := url.Parse(p.BaseURL); !modelprovider.Protocol(p.Protocol).ValidBasePath(base.Path) { - return &ModelProviderError{Code: "model_provider_base_url_invalid", Param: "base_url", message: "an anthropic base_url excludes the /v1 version path"} - } - if !registry.SupportsProtocol(p.Protocol) { - return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: "unsupported model provider protocol"} - } - if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > 16384 || strings.ContainsAny(p.APIKey, "\x00\r\n") { - return &ModelProviderError{Code: "model_provider_api_key_invalid", Param: "api_key", message: "invalid model provider API key"} + return &ModelProviderError{Code: "invalid_model_provider", message: "model_provider is required"} } - if p.ContextWindow < 0 || p.MaxOutputTokens < 0 || (p.MaxOutputTokens > p.ContextWindow) { - param := "max_output_tokens" - if p.ContextWindow < 0 { - param = "context_window" - } - return &ModelProviderError{Code: "model_provider_token_limits_invalid", Param: param, message: "invalid model token limits"} + err := p.Provider().Validate(false) + var field *modelprovider.FieldError + if !errors.As(err, &field) { + return err } - return nil + return &ModelProviderError{Code: modelProviderErrorCodes[field.Field], Param: field.Field, message: field.Error()} } -func (p *ModelProviderInput) ValidateHarness(harness string) error { - return p.ValidateHarnessWithRegistry(harness, builtin.Registry()) -} - -// ValidateHarnessWithRegistry validates provider input against adapter-owned rules. +// ValidateHarness also validates provider input against adapter-owned rules. // It does not enable an execution engine or placement. -func (p *ModelProviderInput) ValidateHarnessWithRegistry(harness string, registry harnessconfig.Registry) error { - if err := p.validate(registry); err != nil { +func (p *ModelProviderInput) ValidateHarness(harness string) error { + if err := p.Validate(); err != nil { return err } - configuration, _ := registry.Lookup(harness) + configuration, _ := builtin.Registry().Lookup(harness) if err := configuration.ValidateProtocol(p.Protocol); err != nil { return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: err.Error()} } diff --git a/contracts/agents-api/v1/model_execution_test.go b/contracts/agents-api/v1/model_execution_test.go index 4c678fbb9..cc8b7a7d2 100644 --- a/contracts/agents-api/v1/model_execution_test.go +++ b/contracts/agents-api/v1/model_execution_test.go @@ -27,17 +27,6 @@ func TestModelExecutionValidation(t *testing.T) { t.Fatalf("unsupported protocol or harness accepted: %s/%s", tc.protocol, tc.harness) } } - for _, url := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?key=secret", "https://example.com#secret", "https://", - "https://example.com:99999/v1", "https://example.com:0/v1", "https://xn--.test", "https://xn--a.test", "https://a..b", "https://999.1.1.1"} { - if (&ModelProviderInput{Protocol: "responses", BaseURL: url, APIKey: "secret"}).Validate() == nil { - t.Fatal("unsafe or unusable provider URL accepted", url) - } - } - for _, url := range []string{"https://example.com:8443/v1", "https://127.0.0.1/v1", "https://[::1]:8443/v1", "https://model_gateway.internal/v1", "https://bücher.example/v1"} { - if err := (&ModelProviderInput{Protocol: "responses", BaseURL: url, APIKey: "secret"}).Validate(); err != nil { - t.Fatal("valid provider URL rejected", url, err) - } - } if (&ModelProviderInput{Protocol: "anthropic", BaseURL: "https://example.com", APIKey: "secret"}).ValidateHarness("mcode") == nil { t.Fatal("MiniMax Code accepted unknown model limits") } diff --git a/contracts/agents-api/v1/model_provider_admission.go b/contracts/agents-api/v1/model_provider_admission.go deleted file mode 100644 index d5ac0d495..000000000 --- a/contracts/agents-api/v1/model_provider_admission.go +++ /dev/null @@ -1,59 +0,0 @@ -package v1 - -import ( - "net" - "net/url" - "strconv" - "strings" - - "golang.org/x/net/idna" -) - -const providerScheme = "https" - -// providerHost converts a domain as URL host parsing does (UTS #46 without -// hyphen or STD3 restrictions), rejecting invalid labels such as bad punycode. -var providerHost = idna.New(idna.MapForLookup(), idna.BidiRule(), idna.StrictDomainName(false), idna.CheckHyphens(false)) - -// Model provider sources, as recorded in a Session's execution configuration. -const ( - ModelProviderSourceSession = "session" - ModelProviderSourceAgent = "agent" - ModelProviderSourceDeployment = "deployment" -) - -func validModelProviderBaseURL(base string) bool { - u, err := url.Parse(base) - return err == nil && u.Scheme == providerScheme && validModelProviderHost(u) && u.User == nil && u.RawQuery == "" && u.Fragment == "" && !strings.ContainsAny(base, "\x00\r\n") -} - -// validModelProviderHost requires a usable host: an IP address, or a domain -// whose labels are nonempty letters, digits, hyphens and underscores and whose -// final label is not numeric. Any port must be in 1-65535. -func validModelProviderHost(u *url.URL) bool { - if port := u.Port(); port != "" { - if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { - return false - } - } - host := u.Hostname() - if net.ParseIP(host) != nil { - return true - } - ascii, err := providerHost.ToASCII(host) - if err != nil { - return false - } - labels := strings.Split(strings.TrimSuffix(ascii, "."), ".") - for _, label := range labels { - if label == "" || len(label) > 63 || label == "xn--" || strings.IndexFunc(label, invalidHostRune) >= 0 { - return false - } - } - // A numeric final label makes the host an IPv4 address, which ParseIP rejected. - return strings.Trim(labels[len(labels)-1], "0123456789") != "" -} - -func invalidHostRune(r rune) bool { - return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_') -} diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md index a0d533547..02cfd5bc3 100644 --- a/contracts/agents-api/zh/core-errors.md +++ b/contracts/agents-api/zh/core-errors.md @@ -1,7 +1,7 @@ --- title: "Core 管理错误" source: contracts/agents-api/core-errors.md -source_hash: 78fcbde855beafae4d1f5eb38b87596eeca25c99c025c6c54978db988d2a534a +source_hash: 50b9624c14d3d600f991fcc9da741b6c4c4722831568c28e849a574bb34a4b06 --- `/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。 @@ -60,9 +60,9 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封 | `invalid_name` | `name` | `max_length`:Projects 和节点为 128,Project 键为 80 | 名称未通过相应资源的验证器 | | `invalid_node_capacity` | `max_active` 或 `max_retained` | `min`:1,`max`:1000000 | 容量无效;保留容量还必须至少等于活动容量 | | `invalid_model_provider` | null | 省略 | 必须提供完整的模型提供商配置包 | -| `model_provider_base_url_invalid` | `base_url` | 省略 | 必须使用 HTTPS,且不得包含凭据、查询或片段 | +| `model_provider_base_url_invalid` | `base_url` | 省略 | 基础 URL 不符合[提供商规则](./model-execution.md#session-override) | | `model_provider_protocol_unsupported` | `protocol` | `harness` 和 `allowed_protocols`,来自该构建的适配器目录 | 协议未知,或所选 Harness 不支持该协议 | -| `model_provider_api_key_invalid` | `api_key` | `max_length`:16384 | 密钥为空、过长或包含禁止字符 | +| `model_provider_api_key_invalid` | `api_key` | `max_length`:16384 | 密钥不符合[提供商规则](./model-execution.md#session-override) | | `model_provider_token_limits_invalid` | `context_window` 或 `max_output_tokens` | 省略 | 限制无效,或 Harness 要求的正数限制缺失 | | `model_configuration_model_invalid` | `model` | 省略 | 部署默认配置的 model 不是非空模型标识符 | | `harness_config_invalid` | `harness_config` | 省略 | 部署默认配置的原生参数不受支持或无效 | diff --git a/contracts/agents-api/zh/model-execution.md b/contracts/agents-api/zh/model-execution.md index f2d24efd8..41650bc31 100644 --- a/contracts/agents-api/zh/model-execution.md +++ b/contracts/agents-api/zh/model-execution.md @@ -1,10 +1,10 @@ --- title: "模型执行" source: contracts/agents-api/model-execution.md -source_hash: a169c29bd481bd32ae392a45c76ac8822df176e113b128a05d55dfe10250fe08 +source_hash: e0d23ec03ffdfa296dcfb02fb595164ed4170183a89f1c6c209bdc8928e5c595 --- -每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 负责验证冻结的提供商连接并声明[凭据网关](#credential-gateway)转发的内容,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 +每个 Session 都运行一个 Harness,并使用一个模型提供商。Core 通过三个固定版本上游协议未定义的 Core 扩展来选择它们:`x_agents_core.harness` 选择 Harness,`x_agents_core.model_provider` 提供端点和密钥,`x_agents_core.harness_config` 携带原生模型参数。Core 没有提供商目录、模型别名解析或产品权限模型;除 Session 和已保存 Agent 配置包外,唯一存储的配置包是每个 Harness 的一个 [deployment default](#deployment-defaults)。本文档定义 Harness—模型提供商协议:[`internal/modelprovider/config.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/modelprovider/config.go) 定义 Core 和 Runtime 共同应用的[提供商规则](#session-override),并声明[凭据网关](#credential-gateway)转发的内容,每个 Harness 则通过 [`internal/harnessconfig/harness.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/harnessconfig/harness.go) 声明其协议和原生参数。 ## Harness 选择 {#harness-selection} @@ -74,7 +74,7 @@ Core 从同一个数据库快照读取 Agent 配置和加密配置包;显式 ``` - `protocol` 指定上游 API(`anthropic`、`responses` 或 `chat_completions`),而不是引擎。所选 Harness 必须原生支持它。 -- `base_url` 使用 HTTPS 和有效主机名,且不得包含凭据、查询参数或片段。回环主机指 agent host 的网络命名空间,对每种 Environment 类型都是如此。`anthropic` 的 `base_url` 不包含版本路径,因为 Harness 会自行追加 `/v1/messages`,所以以 `/v1` 或 `/v1/` 结尾的值会被拒绝。 +- `base_url` 使用 HTTPS 和有效主机名,且不得包含凭据、查询参数或片段。回环主机指 agent host 的网络命名空间,对每种 Environment 类型都是如此。Core 拒绝普通 `http`;Runtime 仅接受指向回环 IP 地址的 `http`,用于它交给 Harness 的凭据网关监听地址。`anthropic` 的 `base_url` 不包含版本路径,因为 Harness 会自行追加 `/v1/messages`,所以以 `/v1` 或 `/v1/` 结尾的值会被拒绝。 - `api_key` 不得为空,最长为 16 KiB,并且不得包含 NUL、CR 或 LF。 - `context_window` 和 `max_output_tokens` 是可选的非负整数,输出限制不得大于上下文限制;对于 MiniMax Code,两者都必须为正数。请使用真实模型的限制。 - `agent.model` 是准确的提供商模型 ID;只要提供该值,就始终会替换部署模型。 diff --git a/internal/harnessconfig/configuration_test.go b/internal/harnessconfig/configuration_test.go index e2155ea45..e8f04a81d 100644 --- a/internal/harnessconfig/configuration_test.go +++ b/internal/harnessconfig/configuration_test.go @@ -15,7 +15,7 @@ func TestRegistryOwnsDeclarations(t *testing.T) { if again.Validate("responses", 0, 0) == nil { t.Fatal("lookup mutated the registered declaration") } - if _, ok := registry.Lookup("unknown"); ok || registry.SupportsProtocol("changed") || !registry.SupportsProtocol("responses") { + if _, ok := registry.Lookup("unknown"); ok { t.Fatal("unknown declarations were inferred") } } diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 72d2736fa..f2caf7210 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -40,8 +40,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) -// Provider is deliberately limited to configuration compatibility. Core owns -// credential admission and endpoint security; native launch options stay private. +// Provider is deliberately limited to configuration compatibility. +// internal/modelprovider owns the credential and endpoint rule; native launch +// options stay private. type Provider struct { Protocol string RequiresTokenLimits bool @@ -91,7 +92,8 @@ func (c Configuration) Prepare(req proto.PromptRequestPayload) (PreparedConfigur return PreparedConfiguration{}, ErrModelProvider } provider := *req.ModelProvider - if err := provider.Validate(); err != nil { + // A view hands its Harness the credential gateway's loopback http listener. + if err := provider.Validate(true); err != nil { return PreparedConfiguration{}, err } if err := c.Validate(string(provider.Protocol), provider.ContextWindow, provider.MaxOutputTokens); err != nil { @@ -180,12 +182,3 @@ func (r Registry) Validate(kind, protocol string, contextWindow, maxOutputTokens configuration, _ := r.Lookup(kind) return configuration.Validate(protocol, contextWindow, maxOutputTokens) } - -func (r Registry) SupportsProtocol(protocol string) bool { - for _, configuration := range r.configurations { - if _, ok := configuration.Provider(protocol); ok { - return true - } - } - return false -} diff --git a/internal/modelprovider/config.go b/internal/modelprovider/config.go index c9b47bd2c..36df31ba1 100644 --- a/internal/modelprovider/config.go +++ b/internal/modelprovider/config.go @@ -8,7 +8,10 @@ import ( "net" "net/url" "slices" + "strconv" "strings" + + "golang.org/x/net/idna" ) type Protocol string @@ -31,41 +34,89 @@ type Provider struct { var ErrConfiguration = errors.New("invalid model provider configuration") +// MaxAPIKeyLength is the longest API key accepted, in bytes. +const MaxAPIKeyLength = 16384 + +// FieldError rejects one Provider field, named as in JSON. Its message never +// contains the submitted value. +type FieldError struct { + Field string + message string +} + +func (e *FieldError) Error() string { return e.message } + // Protocols is the single vocabulary of supported upstream protocol formats. // The Harness catalog generator projects it to the TypeScript client. func Protocols() []Protocol { return []Protocol{Anthropic, Responses, ChatCompletions} } func (p Protocol) Valid() bool { return slices.Contains(Protocols(), p) } -// ValidBasePath reports whether a base URL's path suits the protocol. The -// anthropic routes begin with the version path, so an anthropic base URL -// excludes it: a path ending in "/v1", or "/v1/", would reach "/v1/v1/messages". -func (p Protocol) ValidBasePath(path string) bool { - return p != Anthropic || !strings.HasSuffix(strings.TrimRight(path, "/"), "/v1") -} - -func (p Provider) Validate() error { - if !p.Protocol.Valid() { - return ErrConfiguration - } +// Validate is the only provider rule, for Core and the Runtime alike. The base +// URL is https; loopbackHTTP also admits http to a loopback IP address, which +// only the credential gateway's listener that a view hands its Harness uses. +// The first rejected field is reported in the order base URL, protocol, API +// key, token limits. +func (p Provider) Validate(loopbackHTTP bool) error { u, err := url.Parse(p.BaseURL) - if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(p.BaseURL, "\x00\r\n") || !p.Protocol.ValidBasePath(u.Path) { - return ErrConfiguration + if err != nil || !(u.Scheme == "https" || loopbackHTTP && u.Scheme == "http" && net.ParseIP(u.Hostname()).IsLoopback()) || + !validHost(u) || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(p.BaseURL, "\x00\r\n") { + return &FieldError{"base_url", "model provider requires an HTTPS base_url without credentials, query or fragment"} + } + // The anthropic routes begin with the version path, so a base path ending + // in "/v1" or "/v1/" would reach "/v1/v1/messages". + if p.Protocol == Anthropic && strings.HasSuffix(strings.TrimRight(u.Path, "/"), "/v1") { + return &FieldError{"base_url", "an anthropic base_url excludes the /v1 version path"} + } + if !p.Protocol.Valid() { + return &FieldError{"protocol", "unsupported model provider protocol"} } - // Providers require HTTPS. Loopback HTTP exists only for the gateway - // listener a view hands its Harness; Core admits only HTTPS providers. - if u.Scheme != "https" && !(u.Scheme == "http" && net.ParseIP(u.Hostname()).IsLoopback()) { - return ErrConfiguration + if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > MaxAPIKeyLength || strings.ContainsAny(p.APIKey, "\x00\r\n") { + return &FieldError{"api_key", "invalid model provider API key"} } - if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > 16384 || strings.ContainsAny(p.APIKey, "\x00\r\n") { - return ErrConfiguration + if p.ContextWindow < 0 { + return &FieldError{"context_window", "invalid model token limits"} } - if p.ContextWindow < 0 || p.MaxOutputTokens < 0 || p.MaxOutputTokens > p.ContextWindow { - return ErrConfiguration + if p.MaxOutputTokens < 0 || p.MaxOutputTokens > p.ContextWindow { + return &FieldError{"max_output_tokens", "invalid model token limits"} } return nil } +// hostProfile converts a domain as URL host parsing does (UTS #46 without +// hyphen or STD3 restrictions), rejecting invalid labels such as bad punycode. +var hostProfile = idna.New(idna.MapForLookup(), idna.BidiRule(), idna.StrictDomainName(false), idna.CheckHyphens(false)) + +// validHost requires a usable host: an IP address, or a domain whose labels +// are nonempty letters, digits, hyphens and underscores and whose final label +// is not numeric. Any port must be in 1-65535. +func validHost(u *url.URL) bool { + if port := u.Port(); port != "" { + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return false + } + } + if net.ParseIP(u.Hostname()) != nil { + return true + } + ascii, err := hostProfile.ToASCII(u.Hostname()) + if err != nil { + return false + } + labels := strings.Split(strings.TrimSuffix(ascii, "."), ".") + for _, label := range labels { + if label == "" || len(label) > 63 || label == "xn--" || strings.IndexFunc(label, invalidHostRune) >= 0 { + return false + } + } + // A numeric final label makes the host an IPv4 address, which ParseIP rejected. + return strings.Trim(labels[len(labels)-1], "0123456789") != "" +} + +func invalidHostRune(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_') +} + // Placeholder is the credential a Harness receives instead of the upstream key. // It is not secret and authorizes nothing outside the Session's gateway listener. const Placeholder = "oac-gateway-placeholder" diff --git a/internal/modelprovider/config_test.go b/internal/modelprovider/config_test.go index 58afc7561..b713c919a 100644 --- a/internal/modelprovider/config_test.go +++ b/internal/modelprovider/config_test.go @@ -2,57 +2,76 @@ package modelprovider import ( "errors" + "strings" "testing" ) func TestProviderValidate(t *testing.T) { valid := Provider{Protocol: Responses, BaseURL: "https://model.example/api", APIKey: "fixture-upstream-key", ContextWindow: 64000, MaxOutputTokens: 4096} - for _, protocol := range Protocols() { - p := valid - p.Protocol = protocol - if err := p.Validate(); err != nil { - t.Fatalf("%s rejected: %v", protocol, err) - } - } - for name, change := range map[string]func(*Provider){ - "alias": func(p *Provider) { p.Protocol = "openai" }, - "missing key": func(p *Provider) { p.APIKey = "" }, - "newline key": func(p *Provider) { p.APIKey = "fixture\nkey" }, - "URL credentials": func(p *Provider) { p.BaseURL = "https://user:secret@model.example/v1" }, - "remote HTTP": func(p *Provider) { p.BaseURL = "http://model.example/v1" }, - "query": func(p *Provider) { p.BaseURL = "https://model.example/v1?key=secret" }, - "fragment": func(p *Provider) { p.BaseURL = "https://model.example/v1#secret" }, - "negative limit": func(p *Provider) { p.ContextWindow = -1 }, - "excess output": func(p *Provider) { p.MaxOutputTokens = 64001 }, - "empty bundle": func(p *Provider) { *p = Provider{} }, - } { - p := valid - change(&p) - if err := p.Validate(); !errors.Is(err, ErrConfiguration) { - t.Errorf("%s accepted: %v", name, err) - } + baseURL := func(u string) func(*Provider) { return func(p *Provider) { p.BaseURL = u } } + anthropic := func(u string) func(*Provider) { return func(p *Provider) { p.Protocol, p.BaseURL = Anthropic, u } } + apiKey := func(k string) func(*Provider) { return func(p *Provider) { p.APIKey = k } } + limits := func(context, output int32) func(*Provider) { + return func(p *Provider) { p.ContextWindow, p.MaxOutputTokens = context, output } } -} - -func TestAnthropicBaseURLExcludesVersionPath(t *testing.T) { for _, tc := range []struct { - protocol Protocol - baseURL string - valid bool + name string + change func(*Provider) + strict, loopback string // the field rejected in each mode; empty when valid }{ - {Anthropic, "https://model.example", true}, - {Anthropic, "https://model.example/", true}, - {Anthropic, "https://model.example/anthropic", true}, - {Anthropic, "https://model.example/v1beta", true}, - {Anthropic, "https://model.example/v1", false}, - {Anthropic, "https://model.example/v1/", false}, - {Anthropic, "https://model.example/anthropic/v1//", false}, - {Responses, "https://model.example/v1", true}, - {ChatCompletions, "https://model.example/v1/", true}, + {"responses", func(*Provider) {}, "", ""}, + {"chat completions", func(p *Provider) { p.Protocol = ChatCompletions }, "", ""}, + {"anthropic", anthropic("https://model.example/anthropic"), "", ""}, + {"https loopback", baseURL("https://127.0.0.1:8443/v1"), "", ""}, + {"gateway handoff", func(p *Provider) { p.BaseURL, p.APIKey = "http://127.0.0.1:17101", Placeholder }, "base_url", ""}, + {"http IPv6 loopback", baseURL("http://[::1]:17101"), "base_url", ""}, + {"http loopback name", baseURL("http://localhost:17101"), "base_url", "base_url"}, + {"http remote", baseURL("http://model.example/v1"), "base_url", "base_url"}, + {"other scheme", baseURL("ftp://model.example"), "base_url", "base_url"}, + {"port", baseURL("https://model.example:8443/v1"), "", ""}, + {"IPv6 port", baseURL("https://[::1]:8443/v1"), "", ""}, + {"underscore", baseURL("https://model_gateway.internal/v1"), "", ""}, + {"IDN", baseURL("https://bücher.example/v1"), "", ""}, + {"no host", baseURL("https://"), "base_url", "base_url"}, + {"port above range", baseURL("https://model.example:99999/v1"), "base_url", "base_url"}, + {"port zero", baseURL("https://model.example:0/v1"), "base_url", "base_url"}, + {"empty punycode", baseURL("https://xn--.test"), "base_url", "base_url"}, + {"bad punycode", baseURL("https://xn--a.test"), "base_url", "base_url"}, + {"empty label", baseURL("https://a..b"), "base_url", "base_url"}, + {"numeric final label", baseURL("https://999.1.1.1"), "base_url", "base_url"}, + {"credentials", baseURL("https://user:secret@model.example/v1"), "base_url", "base_url"}, + {"query", baseURL("https://model.example/v1?key=secret"), "base_url", "base_url"}, + {"fragment", baseURL("https://model.example/v1#secret"), "base_url", "base_url"}, + {"newline", baseURL("https://model.example/v1\n"), "base_url", "base_url"}, + {"anthropic root", anthropic("https://model.example/"), "", ""}, + {"anthropic v1beta", anthropic("https://model.example/v1beta"), "", ""}, + {"anthropic version path", anthropic("https://model.example/v1"), "base_url", "base_url"}, + {"anthropic version path slash", anthropic("https://model.example/v1/"), "base_url", "base_url"}, + {"anthropic nested version path", anthropic("https://model.example/anthropic/v1//"), "base_url", "base_url"}, + {"alias protocol", func(p *Provider) { p.Protocol = "openai" }, "protocol", "protocol"}, + {"base URL before protocol", func(p *Provider) { p.Protocol, p.BaseURL = "openai", "http://model.example" }, "base_url", "base_url"}, + {"longest key", apiKey(strings.Repeat("k", MaxAPIKeyLength)), "", ""}, + {"long key", apiKey(strings.Repeat("k", MaxAPIKeyLength+1)), "api_key", "api_key"}, + {"blank key", apiKey(" \t"), "api_key", "api_key"}, + {"newline key", apiKey("fixture\nkey"), "api_key", "api_key"}, + {"NUL key", apiKey("fixture\x00key"), "api_key", "api_key"}, + {"protocol before key", func(p *Provider) { p.Protocol, p.APIKey = "openai", "" }, "protocol", "protocol"}, + {"no limits", limits(0, 0), "", ""}, + {"negative context", limits(-1, 0), "context_window", "context_window"}, + {"negative output", limits(10, -1), "max_output_tokens", "max_output_tokens"}, + {"output above context", limits(10, 11), "max_output_tokens", "max_output_tokens"}, + {"context before output", limits(-1, -2), "context_window", "context_window"}, + {"key before limits", func(p *Provider) { p.APIKey, p.ContextWindow = "", -1 }, "api_key", "api_key"}, + {"empty bundle", func(p *Provider) { *p = Provider{} }, "base_url", "base_url"}, } { - err := Provider{Protocol: tc.protocol, BaseURL: tc.baseURL, APIKey: "fixture-upstream-key"}.Validate() - if tc.valid != (err == nil) || err != nil && !errors.Is(err, ErrConfiguration) { - t.Fatalf("%s %s: %v", tc.protocol, tc.baseURL, err) + for loopbackHTTP, want := range map[bool]string{false: tc.strict, true: tc.loopback} { + p := valid + tc.change(&p) + err := p.Validate(loopbackHTTP) + var field *FieldError + if want == "" && err != nil || want != "" && (!errors.As(err, &field) || field.Field != want) { + t.Errorf("%s with loopback http %t: got %v, want rejected field %q", tc.name, loopbackHTTP, err, want) + } } } } diff --git a/internal/modelprovider/routes_test.go b/internal/modelprovider/routes_test.go index a65ebd9d2..18eefc1b4 100644 --- a/internal/modelprovider/routes_test.go +++ b/internal/modelprovider/routes_test.go @@ -58,15 +58,6 @@ func TestLookupRouteMatchesOnlyDeclaredRoutes(t *testing.T) { } } -func TestPlaceholderPassesProviderValidation(t *testing.T) { - for _, protocol := range Protocols() { - gateway := Provider{Protocol: protocol, BaseURL: "http://127.0.0.1:41000", APIKey: Placeholder, ContextWindow: 64000, MaxOutputTokens: 4096} - if err := gateway.Validate(); err != nil { - t.Fatalf("%s rejected the placeholder: %v", protocol, err) - } - } -} - func TestUpstreamPathJoinsBaseAndRoute(t *testing.T) { for _, join := range []struct{ base, route, want string }{ {"", "/responses", "/responses"}, diff --git a/services/core/internal/api/core_validation_errors.go b/services/core/internal/api/core_validation_errors.go index 5310c0d24..3478ca664 100644 --- a/services/core/internal/api/core_validation_errors.go +++ b/services/core/internal/api/core_validation_errors.go @@ -6,6 +6,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" ) @@ -54,7 +55,7 @@ func writeCoreModelProviderError(w http.ResponseWriter, err error, harness strin } details := CoreErrorDetails{} if field.Code == "model_provider_api_key_invalid" { - details["max_length"] = CoreErrorNumber(16384) + details["max_length"] = CoreErrorNumber(modelprovider.MaxAPIKeyLength) } if field.Code == "model_provider_protocol_unsupported" { // Only adapter-owned catalog values may enter details, never a submitted diff --git a/services/core/internal/execution/model_execution.go b/services/core/internal/execution/model_execution.go index 7d6e3318d..63e3512bb 100644 --- a/services/core/internal/execution/model_execution.go +++ b/services/core/internal/execution/model_execution.go @@ -20,6 +20,6 @@ func resolvedSessionModelProvider(provider *v1.ModelProviderInput, engine string if err := provider.ValidateHarness(engine); err != nil { return nil, err } - return &modelprovider.Provider{Protocol: modelprovider.Protocol(provider.Protocol), BaseURL: provider.BaseURL, APIKey: provider.APIKey, - ContextWindow: provider.ContextWindow, MaxOutputTokens: provider.MaxOutputTokens}, nil + resolved := provider.Provider() + return &resolved, nil }