From 649c549b4389fada3687b20775b9f958ce2304ad Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 00:28:05 +0000 Subject: [PATCH 1/2] Declare the native model routes and credentials the credential gateway relays internal/modelprovider declares each protocol's native routes, upstream credential header, the stripped inbound credential headers and the non-secret placeholder, with LookupRoute returning a typed miss. model-execution.md replaces the no-proxy rule with the credential gateway rule. --- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/model-execution.md | 17 +++- docs/api/public-agent-api.md | 2 +- internal/harnessconfig/harness.go | 9 +- internal/modelprovider/config.go | 97 +++++++++++++++++++++- internal/modelprovider/routes_test.go | 57 +++++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 internal/modelprovider/routes_test.go diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index c49f9e1c1..0365dba09 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -204,7 +204,7 @@ Run the `engine` and `execution` tests for omission, policy, combination and err A supplied `model` must be a nonempty string, and an explicit `model_provider` requires it. The native-owned connection path may omit both; explicit null is invalid. An explicitly empty declaration accepts no provider or nonempty native parameters and advertises no provider support. Unknown protocol formats and duplicate protocol declarations fail at registration. -The declaration's ordered `protocols` list is the only source of accepted protocols and the default (the first entry); it also feeds Core's configuration-support descriptor, and Core and Runtime reject unsupported combinations through it. Adapters connect directly through native configuration; they never introduce a model API proxy or protocol converter, a second model capability registry, or capabilities inferred from model names. Claude's private bridge receives compiled native options and performs structural checks only, not a second copy of the declaration's rules. +The declaration's ordered `protocols` list is the only source of accepted protocols and the default (the first entry); it also feeds Core's configuration-support descriptor, and Core and Runtime reject unsupported combinations through it. Adapters connect through native configuration and the [credential gateway](model-execution.md#credential-gateway); they never introduce their own model API proxy or protocol converter, a second model capability registry, or capabilities inferred from model names. Claude's private bridge receives compiled native options and performs structural checks only, not a second copy of the declaration's rules. ## Qualify the adapter diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 669a47ee9..20f3f1671 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -1,6 +1,6 @@ # 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`](../../internal/modelprovider/config.go) validates the frozen provider connection, and each Harness declares its protocols and native parameters through [`internal/harnessconfig/harness.go`](../../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`](../../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`](../../internal/harnessconfig/harness.go). ## Harness selection @@ -32,7 +32,7 @@ Provider precedence is: a complete Session bundle, then a complete saved bundle, | Claude SDK | `anthropic` | | MiniMax Code | `anthropic`, `responses`, `chat_completions` | -MiniMax Code requires positive context and output limits. 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. +MiniMax Code requires positive context and output limits. Core validates the resolved combination before writing the Session. Core and Runtime read the same ordered `protocols` declaration in `internal/harnessconfig`. Nothing converts between protocols, including inside a Harness. Unsupported saved configurations and Session snapshots fail when used; they are never rewritten, aliased or migrated. Which sources apply depends on who owns the compute that receives the key: @@ -88,7 +88,18 @@ Unsupported protocol, Harness or Environment combinations are rejected before th The resolved provider configuration is frozen and encrypted in the Session creation transaction, with its own encryption purpose and Project and Session binding. Creation retries include it in their request hash, so a changed key or endpoint under the same Idempotency-Key conflicts; a key enters any stored hash only as a fingerprint keyed by the deployment credential key. No public Session, Agent, Environment, event or ordinary configuration contains the key. The top-level extension is write-only and cannot be updated. -At dispatch, Core sends the snapshot as one confidential provider bundle over the daemon connection bound to the Session, and the adapter applies it natively and connects directly to the provider. Core never falls back to other credentials when a snapshot is missing or cannot be decrypted. For `self_hosted`, the receiving daemon is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The executor host stores the bundle in its native Harness home, as hosted Runtimes do. Native tools run with the starting account's permissions and can read what that account can read, and revocation does not erase a bundle already delivered. +At dispatch, Core sends the snapshot as one confidential provider bundle over the daemon connection bound to the Session. The [credential gateway](#credential-gateway) holds the key, and the adapter points the Harness at the gateway through native configuration. Core never falls back to other credentials when a snapshot is missing or cannot be decrypted. For `self_hosted`, the receiving daemon is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The executor host stores the bundle in its native Harness home, as hosted Runtimes do. Native tools run with the starting account's permissions and can read what that account can read, and revocation does not erase a bundle already delivered. + +## Credential gateway + +The Harness reaches its frozen upstream through a Session-local credential gateway on the agent host. The Harness's native base URL points at the gateway, and the Harness receives only a non-secret placeholder credential, never the key. + +- The gateway relays only the declared native routes of the provider's protocol, with the request and response unchanged. An undeclared path or method, or an undeclared WebSocket upgrade, is rejected and never reaches the upstream. +- It removes every inbound credential header, then injects the upstream credential in the protocol's declared header. +- It never follows a redirect with the credential. +- It never converts between protocols. + +[`internal/modelprovider/config.go`](../../internal/modelprovider/config.go) declares each protocol's routes and credential header, the stripped headers and the placeholder, and its `LookupRoute` matches a request against them. A Harness that calls a route the table does not declare needs a protocol change, not a gateway exception. ## Native model parameters diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index 3e31a6067..4a71a3286 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -153,7 +153,7 @@ Any other member is rejected with 400. `api_key` is write-only: reads return `ap The harness is the agent program that runs a Session: Codex (`codex`), Claude Code (`claude_sdk`) or MiniMax Code (`mcode`). Set `x_agents_core.harness` on the Agent or the inline `agent`; without it, the installation's default harness applies ([`core.default_harness`](../configuration.md#settings), Codex unless the operator changed it). - **Model.** `model` is the provider's exact model ID. An inline Agent on an `openai_hosted` or `none` Session may omit it to use the default model configuration of its harness. A saved Agent always needs one. -- **Provider.** The harness calls your provider directly, with one of the harness's native protocols; there is no conversion, and a mismatch is rejected when the Session is created. [Model execution](../../contracts/agents-api/model-execution.md#saved-defaults-and-precedence) lists each harness's protocols and which provider a Session uses on each Environment type. A Session freezes its provider at creation. +- **Provider.** The harness calls your provider with one of the harness's native protocols, through a [credential gateway](../../contracts/agents-api/model-execution.md#credential-gateway) that keeps your key out of the harness; there is no conversion, and a mismatch is rejected when the Session is created. [Model execution](../../contracts/agents-api/model-execution.md#saved-defaults-and-precedence) lists each harness's protocols and which provider a Session uses on each Environment type. A Session freezes its provider at creation. - **Native parameters.** `harness_config` carries the harness's own model settings; see [native model parameters](../../contracts/agents-api/model-execution.md#native-model-parameters). Not every combination of harness, placement and operation is supported; the [Harness capabilities](../../contracts/agents-api/harness-capabilities.md) lists them. diff --git a/internal/harnessconfig/harness.go b/internal/harnessconfig/harness.go index 53c9365f3..aeeee544c 100644 --- a/internal/harnessconfig/harness.go +++ b/internal/harnessconfig/harness.go @@ -23,10 +23,11 @@ // Core freezes the model configuration per Session. Its meaning and explicit // settings must hold for the first Turn, later Turns, native retries, tool // continuations and recovery. A Runtime that cannot preserve a frozen snapshot -// rejects it without rewriting, migration or aliases. Native connection setup is -// direct: no model API proxy, passthrough gateway or protocol conversion, including -// inside an adapter. Protocol acceptance does not qualify model capabilities; -// operation and input requirements must still be checked before native submission. +// rejects it without rewriting, migration or aliases. Native connection setup +// goes through the credential gateway that internal/modelprovider declares, with +// no protocol conversion, including inside an adapter. Protocol acceptance does +// not qualify model capabilities; operation and input requirements must still be +// checked before native submission. package harnessconfig import ( diff --git a/internal/modelprovider/config.go b/internal/modelprovider/config.go index 386277d8f..01e18bab4 100644 --- a/internal/modelprovider/config.go +++ b/internal/modelprovider/config.go @@ -1,4 +1,6 @@ -// Package modelprovider validates the frozen upstream connection supplied to a native Harness. +// Package modelprovider validates the frozen upstream connection supplied to a +// native Harness and declares the native routes and credential header that the +// Session's credential gateway relays for each protocol. package modelprovider import ( @@ -7,6 +9,7 @@ import ( "errors" "net" "net/url" + "slices" "strings" ) @@ -78,3 +81,95 @@ func (p Provider) Validate() error { } return nil } + +// 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" + +// Route is one native HTTP route of a protocol. Path is relative to the +// upstream base URL as the protocol's SDKs join it: the gateway relays a +// request to the base URL's path followed by Path, with the query unchanged. +type Route struct { + Method string + Path string + // WebSocket allows an upgrade on this route; otherwise the gateway + // rejects a request that asks for one. + WebSocket bool +} + +// Credential is the upstream credential header the gateway injects. Its value +// is Prefix followed by the key. +type Credential struct { + Header string + Prefix string +} + +func (c Credential) Value(key string) string { return c.Prefix + key } + +// StrippedHeaders lists every inbound credential header the gateway removes +// before it injects the upstream credential. +var StrippedHeaders = []string{"Authorization", "Proxy-Authorization", "X-Api-Key", "Api-Key"} + +// surface is the declared native API of one protocol: the routes the pinned +// Harnesses call and the credential form they send upstream. +type surface struct { + routes []Route + credential Credential +} + +var surfaces = map[Protocol]surface{ + Anthropic: { + routes: []Route{ + {Method: "POST", Path: "/v1/messages"}, + {Method: "POST", Path: "/v1/messages/count_tokens"}, + }, + credential: Credential{Header: "X-Api-Key"}, + }, + Responses: { + routes: []Route{{Method: "POST", Path: "/responses"}}, + credential: Credential{Header: "Authorization", Prefix: "Bearer "}, + }, + ChatCompletions: { + routes: []Route{{Method: "POST", Path: "/chat/completions"}}, + credential: Credential{Header: "Authorization", Prefix: "Bearer "}, + }, +} + +var ( + ErrRouteNotFound = errors.New("model route is not declared") + ErrMethodNotAllowed = errors.New("model route does not allow this method") +) + +// Routes returns the declared routes of a protocol. +func Routes(p Protocol) []Route { return slices.Clone(surfaces[p].routes) } + +// UpstreamCredential returns the credential header the gateway injects for p. +func UpstreamCredential(p Protocol) (Credential, error) { + s, ok := surfaces[p] + if !ok { + return Credential{}, ErrConfiguration + } + return s.credential, nil +} + +// LookupRoute matches a request against the declared routes of p. The path is +// the request's escaped path relative to the base URL and matches exactly, +// with no normalization; the method matches exactly. A declared path with +// another method is ErrMethodNotAllowed; any other path is ErrRouteNotFound. +func LookupRoute(p Protocol, method, path string) (Route, error) { + s, ok := surfaces[p] + if !ok { + return Route{}, ErrConfiguration + } + err := ErrRouteNotFound + for _, route := range s.routes { + if route.Path != path { + continue + } + if route.Method == method { + return route, nil + } + err = ErrMethodNotAllowed + } + return Route{}, err +} diff --git a/internal/modelprovider/routes_test.go b/internal/modelprovider/routes_test.go new file mode 100644 index 000000000..7aec1c538 --- /dev/null +++ b/internal/modelprovider/routes_test.go @@ -0,0 +1,57 @@ +package modelprovider + +import ( + "errors" + "testing" +) + +func TestLookupRouteMatchesOnlyDeclaredRoutes(t *testing.T) { + for _, protocol := range []Protocol{Anthropic, Responses, ChatCompletions} { + routes := Routes(protocol) + if len(routes) == 0 { + t.Fatalf("%s declares no routes", protocol) + } + if _, err := UpstreamCredential(protocol); err != nil { + t.Fatalf("%s declares no credential: %v", protocol, err) + } + for _, route := range routes { + if got, err := LookupRoute(protocol, route.Method, route.Path); err != nil || got != route { + t.Fatalf("%s %s %s did not match: %v", protocol, route.Method, route.Path, err) + } + for _, path := range []string{route.Path + "/", route.Path + "x", "/v1/../" + route.Path[1:], "/" + route.Path} { + if _, err := LookupRoute(protocol, route.Method, path); !errors.Is(err, ErrRouteNotFound) { + t.Fatalf("%s %s matched: %v", protocol, path, err) + } + } + if _, err := LookupRoute(protocol, "DELETE", route.Path); !errors.Is(err, ErrMethodNotAllowed) { + t.Fatalf("%s DELETE %s: %v", protocol, route.Path, err) + } + } + } + for _, miss := range []struct { + protocol Protocol + method, path string + want error + }{ + {Anthropic, "GET", "/v1/models", ErrRouteNotFound}, + {Anthropic, "POST", "/v1/files", ErrRouteNotFound}, + {Anthropic, "post", "/v1/messages", ErrMethodNotAllowed}, + {Anthropic, "POST", "/responses", ErrRouteNotFound}, + {Responses, "POST", "/v1/messages", ErrRouteNotFound}, + {ChatCompletions, "POST", "/responses", ErrRouteNotFound}, + {"openai", "POST", "/responses", ErrConfiguration}, + } { + if _, err := LookupRoute(miss.protocol, miss.method, miss.path); !errors.Is(err, miss.want) { + t.Fatalf("%s %s %s = %v, want %v", miss.protocol, miss.method, miss.path, err, miss.want) + } + } +} + +func TestPlaceholderPassesProviderValidation(t *testing.T) { + for _, protocol := range []Protocol{Anthropic, Responses, ChatCompletions} { + 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) + } + } +} From 4163c0a300d05b336ff6aa10a68d073d0f55f3d4 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 00:40:47 +0000 Subject: [PATCH 2/2] Complete the gateway's stripped headers and define the upstream path join StrippedHeaders adds Cookie and the Codex, Cloudflare AI Gateway and AWS credential headers in canonical form, removed case-insensitively with every value. UpstreamPath trims the base path's trailing slashes and appends the route. model-execution.md keeps the key in the gateway's memory, out of the Harness and the sandbox. --- contracts/agents-api/model-execution.md | 8 +++---- internal/modelprovider/config.go | 27 ++++++++++++++++++----- internal/modelprovider/routes_test.go | 29 ++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 20f3f1671..fb523ae52 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -42,7 +42,7 @@ Which sources apply depends on who owns the compute that receives the key: | `self_hosted` | Accepted | Never applied | 400 `model_provider_required` | | `none` | Rejected with 400 | Applied when configured | Accepted; the device's own environment supplies the model | -The deployment default holds the operator's key, so it stays on operator compute: Core-managed sandboxes and operator-registered `none` devices. A `self_hosted` executor belongs to the application, which supplies its own bundle. Hosted and self-hosted Runtimes carry no model configuration of their own, so a Session there without a bundle is rejected before any write, with param `x_agents_core.model_provider` and a message saying what to configure. +The deployment default holds the operator's key, so it applies only to operator-run Environments: Core-managed sandboxes and operator-registered `none` devices. A `self_hosted` executor belongs to the application, which supplies its own bundle. Hosted and self-hosted Runtimes carry no model configuration of their own, so a Session there without a bundle is rejected before any write, with param `x_agents_core.model_provider` and a message saying what to configure. | Operation | Omitted | Explicit null | | --- | --- | --- | @@ -88,18 +88,18 @@ Unsupported protocol, Harness or Environment combinations are rejected before th The resolved provider configuration is frozen and encrypted in the Session creation transaction, with its own encryption purpose and Project and Session binding. Creation retries include it in their request hash, so a changed key or endpoint under the same Idempotency-Key conflicts; a key enters any stored hash only as a fingerprint keyed by the deployment credential key. No public Session, Agent, Environment, event or ordinary configuration contains the key. The top-level extension is write-only and cannot be updated. -At dispatch, Core sends the snapshot as one confidential provider bundle over the daemon connection bound to the Session. The [credential gateway](#credential-gateway) holds the key, and the adapter points the Harness at the gateway through native configuration. Core never falls back to other credentials when a snapshot is missing or cannot be decrypted. For `self_hosted`, the receiving daemon is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The executor host stores the bundle in its native Harness home, as hosted Runtimes do. Native tools run with the starting account's permissions and can read what that account can read, and revocation does not erase a bundle already delivered. +At dispatch, Core sends the snapshot as one confidential provider bundle over the daemon connection bound to the Session, and the adapter points the Harness at the [credential gateway](#credential-gateway) through native configuration. Core never falls back to other credentials when a snapshot is missing or cannot be decrypted. For `self_hosted`, the receiving daemon is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The gateway holds the key in memory for the Session, and the key never enters the Harness's environment, configuration or home, or the sandbox. ## Credential gateway The Harness reaches its frozen upstream through a Session-local credential gateway on the agent host. The Harness's native base URL points at the gateway, and the Harness receives only a non-secret placeholder credential, never the key. - The gateway relays only the declared native routes of the provider's protocol, with the request and response unchanged. An undeclared path or method, or an undeclared WebSocket upgrade, is rejected and never reaches the upstream. -- It removes every inbound credential header, then injects the upstream credential in the protocol's declared header. +- It removes every value of each stripped header, matching names case-insensitively, then injects the upstream credential in the protocol's declared header. - It never follows a redirect with the credential. - It never converts between protocols. -[`internal/modelprovider/config.go`](../../internal/modelprovider/config.go) declares each protocol's routes and credential header, the stripped headers and the placeholder, and its `LookupRoute` matches a request against them. A Harness that calls a route the table does not declare needs a protocol change, not a gateway exception. +[`internal/modelprovider/config.go`](../../internal/modelprovider/config.go) declares each protocol's routes and credential header, the stripped headers and the placeholder. Its `LookupRoute` matches a request against the routes, and `UpstreamPath` joins a matched route to the upstream base URL. A Harness that calls a route the table does not declare needs a protocol change, not a gateway exception. ## Native model parameters diff --git a/internal/modelprovider/config.go b/internal/modelprovider/config.go index 01e18bab4..5fbc33420 100644 --- a/internal/modelprovider/config.go +++ b/internal/modelprovider/config.go @@ -87,8 +87,8 @@ func (p Provider) Validate() error { const Placeholder = "oac-gateway-placeholder" // Route is one native HTTP route of a protocol. Path is relative to the -// upstream base URL as the protocol's SDKs join it: the gateway relays a -// request to the base URL's path followed by Path, with the query unchanged. +// upstream base URL; UpstreamPath joins the two, and the gateway relays the +// request's query unchanged. type Route struct { Method string Path string @@ -97,6 +97,12 @@ type Route struct { WebSocket bool } +// UpstreamPath is the escaped upstream path for a route: the base URL's escaped +// path with every trailing "/" removed, followed by the route's Path. +func UpstreamPath(baseEscapedPath, routePath string) string { + return strings.TrimRight(baseEscapedPath, "/") + routePath +} + // Credential is the upstream credential header the gateway injects. Its value // is Prefix followed by the key. type Credential struct { @@ -106,9 +112,20 @@ type Credential struct { func (c Credential) Value(key string) string { return c.Prefix + key } -// StrippedHeaders lists every inbound credential header the gateway removes -// before it injects the upstream credential. -var StrippedHeaders = []string{"Authorization", "Proxy-Authorization", "X-Api-Key", "Api-Key"} +// StrippedHeaders lists, in canonical MIME form, every inbound credential +// header that a pinned Harness or its SDK can send. The gateway matches the +// names case-insensitively and removes every value of each before it injects +// the upstream credential. +var StrippedHeaders = []string{ + "Authorization", + "Proxy-Authorization", + "Cookie", + "X-Api-Key", + "Api-Key", + "X-Openai-Actor-Authorization", + "Cf-Aig-Authorization", + "X-Amz-Security-Token", +} // surface is the declared native API of one protocol: the routes the pinned // Harnesses call and the credential form they send upstream. diff --git a/internal/modelprovider/routes_test.go b/internal/modelprovider/routes_test.go index 7aec1c538..f80647758 100644 --- a/internal/modelprovider/routes_test.go +++ b/internal/modelprovider/routes_test.go @@ -2,18 +2,29 @@ package modelprovider import ( "errors" + "net/http" + "slices" "testing" ) func TestLookupRouteMatchesOnlyDeclaredRoutes(t *testing.T) { + for _, header := range StrippedHeaders { + if http.CanonicalHeaderKey(header) != header { + t.Fatalf("stripped header %q is not canonical", header) + } + } for _, protocol := range []Protocol{Anthropic, Responses, ChatCompletions} { routes := Routes(protocol) if len(routes) == 0 { t.Fatalf("%s declares no routes", protocol) } - if _, err := UpstreamCredential(protocol); err != nil { + credential, err := UpstreamCredential(protocol) + if err != nil { t.Fatalf("%s declares no credential: %v", protocol, err) } + if !slices.Contains(StrippedHeaders, credential.Header) { + t.Fatalf("%s credential header %s is not stripped", protocol, credential.Header) + } for _, route := range routes { if got, err := LookupRoute(protocol, route.Method, route.Path); err != nil || got != route { t.Fatalf("%s %s %s did not match: %v", protocol, route.Method, route.Path, err) @@ -55,3 +66,19 @@ func TestPlaceholderPassesProviderValidation(t *testing.T) { } } } + +func TestUpstreamPathJoinsBaseAndRoute(t *testing.T) { + for _, join := range []struct{ base, route, want string }{ + {"", "/responses", "/responses"}, + {"/", "/responses", "/responses"}, + {"/v1", "/responses", "/v1/responses"}, + {"/v1/", "/responses", "/v1/responses"}, + {"/v1//", "/responses", "/v1/responses"}, + {"/anthropic", "/v1/messages", "/anthropic/v1/messages"}, + {"/a%2Fb/v1", "/chat/completions", "/a%2Fb/v1/chat/completions"}, + } { + if got := UpstreamPath(join.base, join.route); got != join.want { + t.Fatalf("UpstreamPath(%q, %q) = %q, want %q", join.base, join.route, got, join.want) + } + } +}