Validate and compose provider endpoints
This commit is contained in:
@@ -166,7 +166,7 @@ extra_params:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | yes | Profile identifier, trimmed before selection and publication. It must be non-empty after trimming and unique within one source after normalization. |
|
| `id` | yes | Profile identifier, trimmed before selection and publication. It must be non-empty after trimming and unique within one source after normalization. |
|
||||||
| `backend` | unless `endpoint` is present | Backend registry ID. It is trimmed and registry membership is checked when the profile is prepared or inspected. |
|
| `backend` | unless `endpoint` is present | Backend registry ID. It is trimmed and registry membership is checked when the profile is prepared or inspected. |
|
||||||
| `endpoint` | unless `backend` is present | Non-empty OpenAI-compatible base URL, including an API version path when required. When both connection fields are present, this overrides the backend endpoint without changing backend identity. |
|
| `endpoint` | unless `backend` is present | OpenAI-compatible base URL, including an API version path when required. A nonempty value is trimmed and must be absolute HTTP or HTTPS with a host and without user information, a query, or a fragment. When both connection fields are present, this overrides the backend endpoint without changing backend identity. |
|
||||||
| `model` | yes | Non-empty provider model name. |
|
| `model` | yes | Non-empty provider model name. |
|
||||||
| `temperature` | no | Number from 0 through 2. |
|
| `temperature` | no | Number from 0 through 2. |
|
||||||
| `max_tokens` | no | Integer zero or greater. |
|
| `max_tokens` | no | Integer zero or greater. |
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ that produce these outbound settings.
|
|||||||
|
|
||||||
Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
||||||
Before the client is called, the engine resolves framework, backend, profile,
|
Before the client is called, the engine resolves framework, backend, profile,
|
||||||
and request values into one execution target. A non-empty endpoint from that
|
and request values into one execution target. Endpoint configuration is trimmed
|
||||||
target overrides the client's configured base URL. After trailing slashes are
|
and must be an absolute HTTP or HTTPS URL with a host and without user
|
||||||
removed, `/chat/completions` is appended. Generation fails before sending when
|
information, a query, or a fragment. A non-empty endpoint from the target
|
||||||
neither source supplies an endpoint.
|
overrides the client's configured base URL. The final selected endpoint is
|
||||||
|
validated again before transport.
|
||||||
|
|
||||||
|
The completion URL is composed through parsed URL path operations. Nested base
|
||||||
|
paths are retained, repeated trailing slashes are normalized, and the result
|
||||||
|
has exactly one appended `/chat/completions` suffix. Generation fails before
|
||||||
|
sending when neither source supplies a valid endpoint.
|
||||||
|
|
||||||
The target's backend ID is routing metadata for prepared values, results, and
|
The target's backend ID is routing metadata for prepared values, results, and
|
||||||
injected clients. The built-in client does not derive the URL from that ID and
|
injected clients. The built-in client does not derive the URL from that ID and
|
||||||
|
|||||||
@@ -25,16 +25,19 @@ and request precedence. The client uses its endpoint, credential metadata,
|
|||||||
generation fields, and extra parameters. `BackendID` remains routing metadata
|
generation fields, and extra parameters. `BackendID` remains routing metadata
|
||||||
for the generation boundary and is not mapped into the provider payload.
|
for the generation boundary and is not mapped into the provider payload.
|
||||||
|
|
||||||
Construction validates the configured base URL and clones any supplied
|
Construction trims and validates a nonempty configured base URL and clones any
|
||||||
`http.Client` so Promptkit can apply its timeout default without mutating the
|
supplied `http.Client` so Promptkit can apply its timeout default without
|
||||||
caller's client. Generation then:
|
mutating the caller's client. An empty configured base remains valid because a
|
||||||
|
resolved request target may supply the endpoint. Generation then:
|
||||||
|
|
||||||
1. validates shared execution-setting invariants and endpoint requirements;
|
1. validates shared execution-setting invariants and the final selected base
|
||||||
|
endpoint;
|
||||||
2. maps the internal request into the OpenAI-compatible chat payload;
|
2. maps the internal request into the OpenAI-compatible chat payload;
|
||||||
3. validates and merges extra parameters;
|
3. validates and merges extra parameters;
|
||||||
4. resolves authentication;
|
4. composes `/chat/completions` through parsed URL path operations;
|
||||||
5. performs the outbound request under the applicable deadlines; and
|
5. resolves authentication;
|
||||||
6. decodes the first response choice and token usage.
|
6. performs the outbound request under the applicable deadlines; and
|
||||||
|
7. decodes the first response choice and token usage.
|
||||||
|
|
||||||
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
||||||
when validating extra parameters. Backend registration consumes the same rule
|
when validating extra parameters. Backend registration consumes the same rule
|
||||||
@@ -65,6 +68,10 @@ configuration, invalid generation requests, request execution failures,
|
|||||||
non-success provider statuses, and malformed successful responses. Provider
|
non-success provider statuses, and malformed successful responses. Provider
|
||||||
response bodies are not included in non-success errors.
|
response bodies are not included in non-success errors.
|
||||||
|
|
||||||
|
Invalid nonempty configured endpoints are configuration failures. A missing or
|
||||||
|
invalid final selected endpoint is an invalid generation request and is
|
||||||
|
rejected before transport.
|
||||||
|
|
||||||
An `http.Client.Do` failure is represented by a redacting multi-cause error:
|
An `http.Client.Do` failure is represented by a redacting multi-cause error:
|
||||||
the package request-failure sentinel and the exact returned transport error are
|
the package request-failure sentinel and the exact returned transport error are
|
||||||
both available through `errors.Is` and `errors.As`, while the rendered text
|
both available through `errors.Is` and `errors.As`, while the rendered text
|
||||||
@@ -80,7 +87,8 @@ The
|
|||||||
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
||||||
own configuration, client cloning, deterministic deadline precedence,
|
own configuration, client cloning, deterministic deadline precedence,
|
||||||
authentication, request and response mapping, malformed data, error identity,
|
authentication, request and response mapping, malformed data, error identity,
|
||||||
cancellation, endpoint selection, and response-body suppression. The root
|
cancellation, endpoint selection and composition, pre-transport rejection, and
|
||||||
|
response-body suppression. The root
|
||||||
transport contract tests also verify that resolved backend settings reach this
|
transport contract tests also verify that resolved backend settings reach this
|
||||||
client without serializing backend identity and that ordinary-run cancellation
|
client without serializing backend identity and that ordinary-run cancellation
|
||||||
retains its public generation and context identities. All use local test
|
retains its public generation and context identities. All use local test
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ contributor workflow and validation.
|
|||||||
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
||||||
| `internal/backend` | Constructs each engine's immutable registry from the built-in OpenRouter definition and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
| `internal/backend` | Constructs each engine's immutable registry from the built-in OpenRouter definition and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
||||||
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, session identifiers, and output contracts. Source parsing, required fields, source-specific normalization and defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go) |
|
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) |
|
||||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ on consumers or on Scriptorium.
|
|||||||
|
|
||||||
`internal/domain` owns source-neutral invariants for values shared across
|
`internal/domain` owns source-neutral invariants for values shared across
|
||||||
multiple input and execution boundaries, including execution-setting bounds,
|
multiple input and execution boundaries, including execution-setting bounds,
|
||||||
session identifiers, and output-contract legality. Callers retain source
|
OpenAI-compatible base endpoints, session identifiers, and output-contract
|
||||||
parsing, required-field rules, source-specific normalization, defaulting,
|
legality. Callers retain source parsing, required-field rules, other
|
||||||
error classification, and other policy specific to their own boundary.
|
source-specific normalization, defaulting, error classification, and policy
|
||||||
|
specific to their own boundary.
|
||||||
|
|
||||||
## Repository And Consumer Boundary
|
## Repository And Consumer Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -1857,7 +1857,7 @@ func TestPrepareWorksWithInMemoryProfilesWithoutProfileFiles(t *testing.T) {
|
|||||||
SchemaDir: frameworkSchemaDir,
|
SchemaDir: frameworkSchemaDir,
|
||||||
}, promptkit.WithProfiles(promptkit.Profile{
|
}, promptkit.WithProfiles(promptkit.Profile{
|
||||||
ID: "memory-profile",
|
ID: "memory-profile",
|
||||||
Endpoint: "http://memory-profile/v1",
|
Endpoint: " https://memory-profile/nested/v1 ",
|
||||||
Model: "memory-model",
|
Model: "memory-model",
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1878,6 +1878,9 @@ func TestPrepareWorksWithInMemoryProfilesWithoutProfileFiles(t *testing.T) {
|
|||||||
if prepared.EffectiveModelParams.Model != "memory-model" {
|
if prepared.EffectiveModelParams.Model != "memory-model" {
|
||||||
t.Fatalf("expected in-memory profile model, got %q", prepared.EffectiveModelParams.Model)
|
t.Fatalf("expected in-memory profile model, got %q", prepared.EffectiveModelParams.Model)
|
||||||
}
|
}
|
||||||
|
if prepared.EffectiveModelParams.Endpoint != "https://memory-profile/nested/v1" {
|
||||||
|
t.Fatalf("expected normalized in-memory profile endpoint, got %q", prepared.EffectiveModelParams.Endpoint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInMemoryProfilesOverrideBuiltInsAndProfileSources(t *testing.T) {
|
func TestInMemoryProfilesOverrideBuiltInsAndProfileSources(t *testing.T) {
|
||||||
@@ -1961,6 +1964,79 @@ func TestWithProfilesRejectsInvalidExecutionSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEndpointValidationMapsPublicErrorCategories(t *testing.T) {
|
||||||
|
t.Run("backend registration is invalid configuration", func(t *testing.T) {
|
||||||
|
_, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{PromptDir: frameworkPromptDir},
|
||||||
|
promptkit.WithBackend(promptkit.Backend{ID: "invalid", Endpoint: "/v1"}),
|
||||||
|
)
|
||||||
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("in-memory profile is invalid configuration", func(t *testing.T) {
|
||||||
|
_, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{PromptDir: frameworkPromptDir},
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "invalid",
|
||||||
|
Endpoint: "https://provider.example/v1?mode=chat",
|
||||||
|
Model: "model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file profile is a profile-load failure", func(t *testing.T) {
|
||||||
|
profileFS := fstest.MapFS{
|
||||||
|
"profiles/invalid.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
id: invalid
|
||||||
|
endpoint: 'https://provider.example/v1#chat'
|
||||||
|
model: model
|
||||||
|
`)},
|
||||||
|
}
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{PromptDir: frameworkPromptDir},
|
||||||
|
promptkit.WithProfileFS(profileFS, "profiles"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine: %v", err)
|
||||||
|
}
|
||||||
|
_, err = engine.InspectProfile(context.Background(), "invalid")
|
||||||
|
if !errors.Is(err, promptkit.ErrProfileLoad) {
|
||||||
|
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("request override is an invalid request", func(t *testing.T) {
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{PromptDir: frameworkPromptDir},
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "valid",
|
||||||
|
Endpoint: "https://provider.example/v1",
|
||||||
|
Model: "model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine: %v", err)
|
||||||
|
}
|
||||||
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
|
ProfileID: "valid",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
|
},
|
||||||
|
Execution: &promptkit.ExecutionTargetOverride{Endpoint: "ftp://provider.example/v1"},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAICompatibleProfileRunsThroughNormalProfilePath(t *testing.T) {
|
func TestOpenAICompatibleProfileRunsThroughNormalProfilePath(t *testing.T) {
|
||||||
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
||||||
prof := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
prof := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -109,10 +108,11 @@ func (r *Registry) CapacityPolicies() map[string]domain.BackendCapacityPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
||||||
definition.Endpoint = strings.TrimSpace(definition.Endpoint)
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(definition.Endpoint)
|
||||||
if err := validateEndpoint(definition.Endpoint); err != nil {
|
if err != nil {
|
||||||
return domain.Backend{}, fmt.Errorf("backend %q endpoint: %w", definition.ID, err)
|
return domain.Backend{}, fmt.Errorf("backend %q endpoint: %w", definition.ID, err)
|
||||||
}
|
}
|
||||||
|
definition.Endpoint = endpoint
|
||||||
|
|
||||||
definition.APIKeyEnv = strings.TrimSpace(definition.APIKeyEnv)
|
definition.APIKeyEnv = strings.TrimSpace(definition.APIKeyEnv)
|
||||||
if definition.APIKeyEnv != "" && !environmentVariableName.MatchString(definition.APIKeyEnv) {
|
if definition.APIKeyEnv != "" && !environmentVariableName.MatchString(definition.APIKeyEnv) {
|
||||||
@@ -182,31 +182,3 @@ func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
|||||||
definition.ExtraParams = extraParams
|
definition.ExtraParams = extraParams
|
||||||
return definition, nil
|
return definition, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateEndpoint(endpoint string) error {
|
|
||||||
if endpoint == "" {
|
|
||||||
return errors.New("must not be blank")
|
|
||||||
}
|
|
||||||
if strings.Contains(endpoint, "#") {
|
|
||||||
return errors.New("must not contain a fragment")
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("must be a valid URL: %w", err)
|
|
||||||
}
|
|
||||||
scheme := strings.ToLower(parsed.Scheme)
|
|
||||||
if scheme != "http" && scheme != "https" {
|
|
||||||
return errors.New("must use http or https")
|
|
||||||
}
|
|
||||||
if !parsed.IsAbs() || parsed.Hostname() == "" {
|
|
||||||
return errors.New("must be absolute and include a host")
|
|
||||||
}
|
|
||||||
if parsed.User != nil {
|
|
||||||
return errors.New("must not contain user information")
|
|
||||||
}
|
|
||||||
if parsed.RawQuery != "" || parsed.ForceQuery {
|
|
||||||
return errors.New("must not contain a query string")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
39
internal/domain/endpoint.go
Normal file
39
internal/domain/endpoint.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeOpenAICompatibleBaseEndpoint trims and validates a source-neutral
|
||||||
|
// OpenAI-compatible provider base endpoint.
|
||||||
|
func NormalizeOpenAICompatibleBaseEndpoint(endpoint string) (string, error) {
|
||||||
|
endpoint = strings.TrimSpace(endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
return "", errors.New("endpoint must not be blank")
|
||||||
|
}
|
||||||
|
if strings.Contains(endpoint, "#") {
|
||||||
|
return "", errors.New("endpoint must not contain a fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("endpoint must be a valid URL")
|
||||||
|
}
|
||||||
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", errors.New("endpoint must use http or https")
|
||||||
|
}
|
||||||
|
if !parsed.IsAbs() || parsed.Hostname() == "" {
|
||||||
|
return "", errors.New("endpoint must be absolute and include a host")
|
||||||
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return "", errors.New("endpoint must not contain user information")
|
||||||
|
}
|
||||||
|
if parsed.RawQuery != "" || parsed.ForceQuery {
|
||||||
|
return "", errors.New("endpoint must not contain a query string")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
47
internal/domain/endpoint_test.go
Normal file
47
internal/domain/endpoint_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeOpenAICompatibleBaseEndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "http host", endpoint: "http://provider.example", want: "http://provider.example"},
|
||||||
|
{name: "https nested path and whitespace", endpoint: " HTTPS://provider.example/api/openai/v1 ", want: "https://provider.example/api/openai/v1"},
|
||||||
|
{name: "IPv4 host and port", endpoint: "http://127.0.0.1:8080/v1", want: "http://127.0.0.1:8080/v1"},
|
||||||
|
{name: "IPv6 host and port", endpoint: "https://[::1]:8443/v1", want: "https://[::1]:8443/v1"},
|
||||||
|
{name: "repeated trailing slashes", endpoint: "https://provider.example/v1///", want: "https://provider.example/v1///"},
|
||||||
|
{name: "blank", endpoint: " \t\n ", wantErr: true},
|
||||||
|
{name: "relative path", endpoint: "/api/v1", wantErr: true},
|
||||||
|
{name: "scheme relative", endpoint: "//provider.example/v1", wantErr: true},
|
||||||
|
{name: "missing host", endpoint: "https:///v1", wantErr: true},
|
||||||
|
{name: "unsupported scheme", endpoint: "ftp://provider.example/v1", wantErr: true},
|
||||||
|
{name: "user information", endpoint: "https://user:secret@provider.example/v1", wantErr: true},
|
||||||
|
{name: "query", endpoint: "https://provider.example/v1?mode=chat", wantErr: true},
|
||||||
|
{name: "empty query", endpoint: "https://provider.example/v1?", wantErr: true},
|
||||||
|
{name: "fragment", endpoint: "https://provider.example/v1#chat", wantErr: true},
|
||||||
|
{name: "empty fragment", endpoint: "https://provider.example/v1#", wantErr: true},
|
||||||
|
{name: "malformed URL", endpoint: "https://provider.example/%zz", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := NormalizeOpenAICompatibleBaseEndpoint(tc.endpoint)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected endpoint error, got %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize endpoint: %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("normalized endpoint = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,9 +51,11 @@ type OpenAICompatibleClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
|
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
|
||||||
baseURL := strings.TrimSpace(cfg.BaseURL)
|
baseURL := ""
|
||||||
if baseURL != "" {
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||||
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
var err error
|
||||||
|
baseURL, err = domain.NormalizeOpenAICompatibleBaseEndpoint(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
|
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +77,7 @@ func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &OpenAICompatibleClient{
|
return &OpenAICompatibleClient{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: baseURL,
|
||||||
defaultModel: cfg.Model,
|
defaultModel: cfg.Model,
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -86,14 +88,18 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
|||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := strings.TrimSpace(req.Target.Endpoint)
|
selectedEndpoint := req.Target.Endpoint
|
||||||
if endpoint == "" {
|
if strings.TrimSpace(selectedEndpoint) == "" {
|
||||||
endpoint = c.baseURL
|
selectedEndpoint = c.baseURL
|
||||||
}
|
}
|
||||||
if endpoint == "" {
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(selectedEndpoint)
|
||||||
return nil, fmt.Errorf("%w: endpoint is required", ErrInvalidRequest)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: invalid endpoint: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
endpoint, err = url.JoinPath(endpoint, defaults.OpenAIChatCompletionsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: invalid endpoint path: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
endpoint = strings.TrimRight(endpoint, "/") + defaults.OpenAIChatCompletionsPath
|
|
||||||
|
|
||||||
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
|
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,14 +64,21 @@ func assertDeadlineNear(t *testing.T, deadline, before, after time.Time, duratio
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewOpenAICompatibleClientRejectsInvalidBaseURL(t *testing.T) {
|
func TestNewOpenAICompatibleClientRejectsInvalidBaseURL(t *testing.T) {
|
||||||
_, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
for _, endpoint := range []string{
|
||||||
BaseURL: "://invalid",
|
"://invalid",
|
||||||
})
|
"/v1",
|
||||||
if err == nil {
|
"https:///v1",
|
||||||
t.Fatal("expected invalid configuration error")
|
"ftp://provider.example/v1",
|
||||||
}
|
"https://user@provider.example/v1",
|
||||||
if !errors.Is(err, ErrInvalidConfig) {
|
"https://provider.example/v1?mode=chat",
|
||||||
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
"https://provider.example/v1#chat",
|
||||||
|
} {
|
||||||
|
t.Run(endpoint, func(t *testing.T) {
|
||||||
|
_, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: endpoint})
|
||||||
|
if !errors.Is(err, ErrInvalidConfig) {
|
||||||
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1312,6 +1319,94 @@ func TestOpenAICompatibleClientAllowsEmptyConfiguredBaseURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientComposesCompletionURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
baseURL string
|
||||||
|
wantURL string
|
||||||
|
}{
|
||||||
|
{name: "HTTP host", baseURL: "http://provider.example", wantURL: "http://provider.example/chat/completions"},
|
||||||
|
{name: "HTTPS host", baseURL: "https://provider.example", wantURL: "https://provider.example/chat/completions"},
|
||||||
|
{name: "nested path", baseURL: "https://provider.example/api/openai/v1", wantURL: "https://provider.example/api/openai/v1/chat/completions"},
|
||||||
|
{name: "trailing slash", baseURL: "https://provider.example/v1/", wantURL: "https://provider.example/v1/chat/completions"},
|
||||||
|
{name: "repeated trailing slashes", baseURL: " https://provider.example/api/v1/// ", wantURL: "https://provider.example/api/v1/chat/completions"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var selectedURL string
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: tc.baseURL,
|
||||||
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
selectedURL = req.URL.String()
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate: %v", err)
|
||||||
|
}
|
||||||
|
if selectedURL != tc.wantURL {
|
||||||
|
t.Fatalf("selected URL = %q, want %q", selectedURL, tc.wantURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientRejectsInvalidSelectedEndpointBeforeTransport(t *testing.T) {
|
||||||
|
invalidEndpoints := []string{
|
||||||
|
"/v1",
|
||||||
|
"https:///v1",
|
||||||
|
"ftp://provider.example/v1",
|
||||||
|
"https://user@provider.example/v1",
|
||||||
|
"https://provider.example/v1?mode=chat",
|
||||||
|
"https://provider.example/v1#chat",
|
||||||
|
"https://sensitive-endpoint.example/%zz",
|
||||||
|
}
|
||||||
|
for _, endpoint := range invalidEndpoints {
|
||||||
|
t.Run(endpoint, func(t *testing.T) {
|
||||||
|
transportCalls := 0
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://configured.example/v1",
|
||||||
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
transportCalls++
|
||||||
|
return nil, errors.New("transport must not be called")
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{Endpoint: endpoint},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), endpoint) {
|
||||||
|
t.Fatalf("error exposed selected endpoint %q: %v", endpoint, err)
|
||||||
|
}
|
||||||
|
if transportCalls != 0 {
|
||||||
|
t.Fatalf("transport calls = %d, want 0", transportCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAICompatibleClientRequiresEndpointWhenUnsetEverywhere(t *testing.T) {
|
func TestOpenAICompatibleClientRequiresEndpointWhenUnsetEverywhere(t *testing.T) {
|
||||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*doma
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, relPath, err)
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, relPath, err)
|
||||||
}
|
}
|
||||||
if err := validateProfile(prof); err != nil {
|
if err := normalizeAndValidateProfile(prof); err != nil {
|
||||||
if errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
if errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
return nil, fmt.Errorf("%w: %s", err, relPath)
|
return nil, fmt.Errorf("%w: %s", err, relPath)
|
||||||
}
|
}
|
||||||
@@ -256,13 +256,21 @@ func requireYAMLStreamEnd(decoder *yaml.Decoder) error {
|
|||||||
return errors.New("profile file must contain exactly one YAML document")
|
return errors.New("profile file must contain exactly one YAML document")
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateProfile(p *domain.ExecutionProfile) error {
|
func normalizeAndValidateProfile(p *domain.ExecutionProfile) error {
|
||||||
if strings.TrimSpace(p.ID) == "" {
|
if strings.TrimSpace(p.ID) == "" {
|
||||||
return errors.New("id is required")
|
return errors.New("id is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(p.BackendID) == "" && strings.TrimSpace(p.Endpoint) == "" {
|
p.Endpoint = strings.TrimSpace(p.Endpoint)
|
||||||
|
if strings.TrimSpace(p.BackendID) == "" && p.Endpoint == "" {
|
||||||
return errors.New("backend or endpoint is required")
|
return errors.New("backend or endpoint is required")
|
||||||
}
|
}
|
||||||
|
if p.Endpoint != "" {
|
||||||
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(p.Endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Endpoint = endpoint
|
||||||
|
}
|
||||||
if strings.TrimSpace(p.Model) == "" {
|
if strings.TrimSpace(p.Model) == "" {
|
||||||
return errors.New("model is required")
|
return errors.New("model is required")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestFilesystemRepository_GetProfile(t *testing.T) {
|
|||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "backend only", connection: "backend: ' openrouter '", wantBackend: "openrouter"},
|
{name: "backend only", connection: "backend: ' openrouter '", wantBackend: "openrouter"},
|
||||||
{name: "endpoint only", connection: "endpoint: http://localhost:8000/v1", wantEndpoint: "http://localhost:8000/v1"},
|
{name: "endpoint only", connection: "endpoint: ' https://localhost:8000/nested/v1 '", wantEndpoint: "https://localhost:8000/nested/v1"},
|
||||||
{name: "both", connection: "backend: openrouter\nendpoint: http://localhost:8000/v1", wantBackend: "openrouter", wantEndpoint: "http://localhost:8000/v1"},
|
{name: "both", connection: "backend: openrouter\nendpoint: http://localhost:8000/v1", wantBackend: "openrouter", wantEndpoint: "http://localhost:8000/v1"},
|
||||||
{name: "neither", wantErr: true},
|
{name: "neither", wantErr: true},
|
||||||
{name: "blank backend", connection: "backend: ' '", wantErr: true},
|
{name: "blank backend", connection: "backend: ' '", wantErr: true},
|
||||||
@@ -351,6 +351,43 @@ model: second
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProfileRepositoriesRejectInvalidEndpoints(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
withBackend bool
|
||||||
|
}{
|
||||||
|
{name: "relative", endpoint: "/v1"},
|
||||||
|
{name: "missing host", endpoint: "https:///v1"},
|
||||||
|
{name: "unsupported scheme", endpoint: "ftp://provider.example/v1"},
|
||||||
|
{name: "user information", endpoint: "https://user@provider.example/v1"},
|
||||||
|
{name: "query", endpoint: "https://provider.example/v1?mode=chat"},
|
||||||
|
{name: "fragment", endpoint: "https://provider.example/v1#chat"},
|
||||||
|
{name: "backend with invalid override", endpoint: "/v1", withBackend: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
backend := ""
|
||||||
|
if tc.withBackend {
|
||||||
|
backend = "backend: openrouter\n"
|
||||||
|
}
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/invalid.yaml": profileMapFile(fmt.Sprintf(
|
||||||
|
"id: invalid-endpoint\nmodel: model\n%sendpoint: %q\n",
|
||||||
|
backend,
|
||||||
|
tc.endpoint,
|
||||||
|
)),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(context.Background(), "invalid-endpoint")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProfileRepositoriesValidateExtraParams(t *testing.T) {
|
func TestProfileRepositoriesValidateExtraParams(t *testing.T) {
|
||||||
const validProfile = `
|
const validProfile = `
|
||||||
id: selected-profile
|
id: selected-profile
|
||||||
|
|||||||
@@ -31,3 +31,53 @@ func TestRunnerPrepareExecutionRejectsInvalidExecutionSettings(t *testing.T) {
|
|||||||
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerPrepareExecutionValidatesAndNormalizesRequestEndpoints(t *testing.T) {
|
||||||
|
newRunner := func() *Runner {
|
||||||
|
return NewRunner(
|
||||||
|
&fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)},
|
||||||
|
&fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}},
|
||||||
|
nil,
|
||||||
|
defaultArtifactReader(),
|
||||||
|
defaultRenderer(),
|
||||||
|
&fakeLLM{forbid: true},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidEndpoints := []string{
|
||||||
|
"/v1",
|
||||||
|
"https:///v1",
|
||||||
|
"ftp://provider.example/v1",
|
||||||
|
"https://user@provider.example/v1",
|
||||||
|
"https://provider.example/v1?mode=chat",
|
||||||
|
"https://provider.example/v1#chat",
|
||||||
|
}
|
||||||
|
for _, endpoint := range invalidEndpoints {
|
||||||
|
t.Run(endpoint, func(t *testing.T) {
|
||||||
|
_, err := newRunner().PrepareExecution(context.Background(), domain.RunRequest{
|
||||||
|
PromptID: "p",
|
||||||
|
ProfileID: "exec",
|
||||||
|
Inputs: singleInputRef(),
|
||||||
|
Execution: &domain.ExecutionTargetOverride{Endpoint: endpoint},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := newRunner().PrepareExecution(context.Background(), domain.RunRequest{
|
||||||
|
PromptID: "p",
|
||||||
|
ProfileID: "exec",
|
||||||
|
Inputs: singleInputRef(),
|
||||||
|
Execution: &domain.ExecutionTargetOverride{Endpoint: " https://provider.example/nested/v1 "},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare normalized endpoint: %v", err)
|
||||||
|
}
|
||||||
|
if got := prepared.Details().EffectiveModelParams.Endpoint; got != "https://provider.example/nested/v1" {
|
||||||
|
t.Fatalf("effective endpoint = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,14 +57,19 @@ func (r *Runner) resolveProfileSelection(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateResolvedExecutionTarget(target domain.ExecutionTarget) error {
|
func normalizeResolvedExecutionTarget(target domain.ExecutionTarget) (domain.ExecutionTarget, error) {
|
||||||
if strings.TrimSpace(target.Endpoint) == "" {
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(target.Endpoint)
|
||||||
return errors.New("execution endpoint is required")
|
if err != nil {
|
||||||
|
return domain.ExecutionTarget{}, fmt.Errorf("execution endpoint: %w", err)
|
||||||
}
|
}
|
||||||
|
target.Endpoint = endpoint
|
||||||
if strings.TrimSpace(target.Model) == "" {
|
if strings.TrimSpace(target.Model) == "" {
|
||||||
return errors.New("execution model is required")
|
return domain.ExecutionTarget{}, errors.New("execution model is required")
|
||||||
}
|
}
|
||||||
return domain.ValidateExecutionTargetSettings(target)
|
if err := domain.ValidateExecutionTargetSettings(target); err != nil {
|
||||||
|
return domain.ExecutionTarget{}, err
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectProfile resolves one explicit profile without prompt or execution work.
|
// InspectProfile resolves one explicit profile without prompt or execution work.
|
||||||
@@ -87,7 +92,8 @@ func (r *Runner) InspectProfile(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
target, _ := resolveExecutionTarget(selection.backend, selection.profile, nil)
|
target, _ := resolveExecutionTarget(selection.backend, selection.profile, nil)
|
||||||
if err := validateResolvedExecutionTarget(target); err != nil {
|
target, err = normalizeResolvedExecutionTarget(target)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err)
|
||||||
}
|
}
|
||||||
target.APIKey = ""
|
target.APIKey = ""
|
||||||
|
|||||||
@@ -317,7 +317,8 @@ func (r *Runner) resolvePreparation(
|
|||||||
|
|
||||||
effectiveModel, targetPresence := resolveExecutionTarget(selection.backend, selection.profile, req.Execution)
|
effectiveModel, targetPresence := resolveExecutionTarget(selection.backend, selection.profile, req.Execution)
|
||||||
effectiveModel.APIKey = req.APIKey
|
effectiveModel.APIKey = req.APIKey
|
||||||
if err := validateResolvedExecutionTarget(effectiveModel); err != nil {
|
effectiveModel, err = normalizeResolvedExecutionTarget(effectiveModel)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %w", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
if err := validateAPIKey(effectiveModel.APIKeyEnv, effectiveModel.APIKey, effectiveModel.APIKeyRequired); err != nil {
|
if err := validateAPIKey(effectiveModel.APIKeyEnv, effectiveModel.APIKey, effectiveModel.APIKeyRequired); err != nil {
|
||||||
|
|||||||
14
profiles.go
14
profiles.go
@@ -100,19 +100,27 @@ func toDomainProfile(publicProfile Profile) (domain.ExecutionProfile, error) {
|
|||||||
APIKeyRequired: publicProfile.APIKeyRequired,
|
APIKeyRequired: publicProfile.APIKeyRequired,
|
||||||
ExtraParams: extraParams,
|
ExtraParams: extraParams,
|
||||||
}
|
}
|
||||||
if err := validatePublicProfile(prof); err != nil {
|
if err := normalizeAndValidatePublicProfile(&prof); err != nil {
|
||||||
return domain.ExecutionProfile{}, err
|
return domain.ExecutionProfile{}, err
|
||||||
}
|
}
|
||||||
return prof, nil
|
return prof, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePublicProfile(prof domain.ExecutionProfile) error {
|
func normalizeAndValidatePublicProfile(prof *domain.ExecutionProfile) error {
|
||||||
if strings.TrimSpace(prof.ID) == "" {
|
if strings.TrimSpace(prof.ID) == "" {
|
||||||
return errors.New("id is required")
|
return errors.New("id is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(prof.BackendID) == "" && strings.TrimSpace(prof.Endpoint) == "" {
|
prof.Endpoint = strings.TrimSpace(prof.Endpoint)
|
||||||
|
if strings.TrimSpace(prof.BackendID) == "" && prof.Endpoint == "" {
|
||||||
return errors.New("backend or endpoint is required")
|
return errors.New("backend or endpoint is required")
|
||||||
}
|
}
|
||||||
|
if prof.Endpoint != "" {
|
||||||
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(prof.Endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prof.Endpoint = endpoint
|
||||||
|
}
|
||||||
if strings.TrimSpace(prof.Model) == "" {
|
if strings.TrimSpace(prof.Model) == "" {
|
||||||
return errors.New("model is required")
|
return errors.New("model is required")
|
||||||
}
|
}
|
||||||
|
|||||||
10
types.go
10
types.go
@@ -297,7 +297,8 @@ type ExecutionTarget struct {
|
|||||||
// empty for endpoint-only profiles. It is supplied to injected LLMClient
|
// empty for endpoint-only profiles. It is supplied to injected LLMClient
|
||||||
// implementations as part of the effective target.
|
// implementations as part of the effective target.
|
||||||
BackendID string `json:"backend_id,omitempty"`
|
BackendID string `json:"backend_id,omitempty"`
|
||||||
// Endpoint is the model-provider base URL.
|
// Endpoint is the normalized absolute HTTP or HTTPS model-provider base URL.
|
||||||
|
// It has a host and no user information, query, or fragment.
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
// Model is the provider model identifier.
|
// Model is the provider model identifier.
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@@ -396,7 +397,9 @@ type PromptInspection struct {
|
|||||||
// framework deadline when no higher-precedence value is present.
|
// framework deadline when no higher-precedence value is present.
|
||||||
type ExecutionTargetOverride struct {
|
type ExecutionTargetOverride struct {
|
||||||
// Endpoint replaces the profile or backend endpoint when non-empty without
|
// Endpoint replaces the profile or backend endpoint when non-empty without
|
||||||
// changing the effective BackendID.
|
// changing the effective BackendID. Preparation trims it and requires an
|
||||||
|
// absolute HTTP or HTTPS URL with a host and no user information, query, or
|
||||||
|
// fragment.
|
||||||
Endpoint string
|
Endpoint string
|
||||||
// Model replaces the profile model when non-empty.
|
// Model replaces the profile model when non-empty.
|
||||||
Model string
|
Model string
|
||||||
@@ -457,7 +460,8 @@ type Profile struct {
|
|||||||
BackendID string
|
BackendID string
|
||||||
// Endpoint is the model-provider base URL. It is required only when
|
// Endpoint is the model-provider base URL. It is required only when
|
||||||
// BackendID is blank and otherwise overrides the backend endpoint when
|
// BackendID is blank and otherwise overrides the backend endpoint when
|
||||||
// non-blank.
|
// non-blank. WithProfiles trims it and requires an absolute HTTP or HTTPS URL
|
||||||
|
// with a host and no user information, query, or fragment.
|
||||||
Endpoint string
|
Endpoint string
|
||||||
// Model is the required non-blank provider model identifier.
|
// Model is the required non-blank provider model identifier.
|
||||||
Model string
|
Model string
|
||||||
|
|||||||
Reference in New Issue
Block a user