Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d79f1e9562 | |||
| c8e4abcdbd | |||
| 6ce54c1d79 | |||
| 2356f7c73d | |||
| ba1e85a805 | |||
| 4c3828857c | |||
| 02fd09955d | |||
| 7389410966 | |||
| 3bc624ec3f | |||
| e5870453f9 | |||
| 06251e385e | |||
| 722b5ddf59 | |||
| 47bc9a6655 | |||
| bd2b7b1862 | |||
| c892eacb65 | |||
| ff73104f7e | |||
| 7a858c2365 | |||
| e4540aa619 |
33
AGENTS.md
33
AGENTS.md
@@ -1,2 +1,35 @@
|
||||
Please review `docs/development.md` for initial orientation in this repository
|
||||
and follow its task-specific reading guide.
|
||||
|
||||
## Implementation Discipline
|
||||
|
||||
Simplify the mechanism, not the user's choices.
|
||||
|
||||
**Keep implementation machinery proportionate.** Use straightforward,
|
||||
idiomatic code and introduce only the machinery needed to support the requested
|
||||
behavior and flexibility. Optimize for conceptual simplicity and
|
||||
maintainability, not for the fewest lines, fewest options, or narrowest product
|
||||
surface.
|
||||
|
||||
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
|
||||
harden, or otherwise narrow behavior unless required by the user, an explicit
|
||||
repository contract, or an unavoidable correctness constraint. Preserve
|
||||
operator and caller choice where policy has not been established. If an
|
||||
additional restriction appears advisable, present it as a decision rather than
|
||||
silently implementing it.
|
||||
|
||||
A correctness constraint must follow from the requested behavior or an
|
||||
established invariant, not from the convenience of the chosen implementation.
|
||||
Explain the specific failure it prevents and why a less restrictive
|
||||
implementation cannot satisfy the contract.
|
||||
|
||||
**An implementation detail is not repository policy.** Tests and documentation
|
||||
must not elevate incidental behavior into a durable guarantee or restriction
|
||||
unless that behavior was expressly selected as part of the contract. Existing
|
||||
explicit contracts remain authoritative; changing or weakening one requires a
|
||||
deliberate decision rather than being inferred from this principle.
|
||||
|
||||
Agent-authored plans, tests, documentation, and prior implementations are not
|
||||
evidence that an additional restriction was deliberately approved. Identify the
|
||||
authorizing requirement or decision; when that authority is unclear, surface
|
||||
the ambiguity rather than treating repetition as approval.
|
||||
|
||||
@@ -33,6 +33,9 @@ boundary and constraints that framework work must preserve.
|
||||
|
||||
## Release Guidance
|
||||
|
||||
Consumers upgrading to the `v0.10.0` profile-policy transition
|
||||
should read the [v0.10.0 transition guide](docs/releases/v0.10.0.md).
|
||||
|
||||
Consumers upgrading from `v0.8.0` to `v0.9.0` should read the
|
||||
[v0.9.0 changelog and migration guide](docs/releases/v0.9.0.md).
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ func WithBackend(backend Backend) Option {
|
||||
queueCapacity = *backend.QueueCapacity
|
||||
}
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
options.backends = append(options.backends, domain.Backend{
|
||||
options.internal.AddBackend(domain.Backend{
|
||||
ID: backend.ID,
|
||||
Endpoint: backend.Endpoint,
|
||||
APIKeyEnv: backend.APIKeyEnv,
|
||||
|
||||
@@ -232,6 +232,29 @@ For programmatic profiles,
|
||||
[`OpenAICompatibleProfile`](../../profiles.go) converts ordinary
|
||||
OpenAI-compatible settings into a value accepted by `WithProfiles`.
|
||||
|
||||
### Choose A Profile Ownership Model
|
||||
|
||||
Choose the profile source that matches the application's continuity needs:
|
||||
|
||||
- Select `promptkit-openrouter-standard` or `promptkit-openrouter-auto` when
|
||||
the maintained literal name is the desired consumer contract. Their current
|
||||
selections are in the [format reference](../formats.md#built-in-profile-catalog).
|
||||
- Select an external catalog profile directly when the application accepts that
|
||||
profile's catalog volatility. The selected external inventories are linked
|
||||
from the [format reference](../formats.md#external-profile-inventories).
|
||||
- Define a complete application-owned profile with an explicit backend or
|
||||
endpoint, model, and settings when the application owns those choices.
|
||||
|
||||
An application profile that inherits an external base follows later changes to
|
||||
that base and fails if the base disappears; it is not a frozen copy. Promptkit
|
||||
tests the catalog versions selected in its module graph. Independently advancing
|
||||
a catalog dependency can remove an inherited base and produce an untested
|
||||
combination. Pinning the resolved module graph preserves the embedded data for
|
||||
a build, but cannot preserve provider availability. Running binaries neither
|
||||
download catalog updates nor automatically replace withdrawn models. See the
|
||||
[release policy](../release.md#catalog-profile-compatibility) for the exact
|
||||
cross-version guarantees.
|
||||
|
||||
### Alias A Built-In Profile
|
||||
|
||||
Give an application-owned profile ID a built-in base when prompts should select
|
||||
@@ -241,7 +264,7 @@ only the setting it owns:
|
||||
```go
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "weather-light",
|
||||
BaseProfileID: "deepseek-4-flash",
|
||||
BaseProfileID: "promptkit-openrouter-standard",
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
```
|
||||
@@ -250,7 +273,9 @@ Select `weather-light` in a prompt or `RunRequest.ProfileID`; it remains the
|
||||
reported selected profile. See the [profile inheritance format
|
||||
reference](../formats.md#profile-inheritance) and the
|
||||
[`Profile` GoDoc](../../types.go) for exact lookup, merging, and validation
|
||||
behavior.
|
||||
behavior. If the base is an external profile, apply the volatility guidance
|
||||
above when deciding whether to use an application-owned complete definition
|
||||
instead.
|
||||
|
||||
### Use The Rakestrawhome Built-In Profile
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Start with:
|
||||
| Root public API | The [architecture policy](policy/architecture.md), [consumer guide](consumers/pkg-promptkit.md), [testing policy](policy/testing.md), and existing GoDoc. |
|
||||
| Prompt, profile, or schema formats | The [framework format reference](formats.md), owning parser or validator package, and [documentation policy](policy/documentation.md). |
|
||||
| Source loading or validation | The [framework format reference](formats.md), [internal source document](internal/sources.md), and owning package tests. |
|
||||
| Maintained external catalogs or built-ins | The [internal source document](internal/sources.md), [framework format reference](formats.md), [testing policy](policy/testing.md), both catalog module repositories, and each catalog's release procedure. |
|
||||
| Maintained external catalogs or built-ins | The [internal source document](internal/sources.md), [framework format reference](formats.md), [testing policy](policy/testing.md), both catalog module repositories, each catalog's release procedure, and the selected module versions in `go.mod`. |
|
||||
| Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. |
|
||||
| Internal package implementation | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and focused internal document listed for that package. |
|
||||
| Tests or test fixtures | The [testing policy](policy/testing.md), owning package, and focused internal document listed by the component overview. |
|
||||
|
||||
@@ -283,7 +283,8 @@ Profile sources resolve matching IDs in this order:
|
||||
2. the ordinary configured source selected by a profile file, `fs.FS`, or
|
||||
configured profile directory;
|
||||
3. application fallback profiles supplied with `WithFallbackProfileFS`; and
|
||||
4. maintained external catalog profiles.
|
||||
4. Promptkit-owned profiles; and
|
||||
5. maintained external catalog profiles.
|
||||
|
||||
A profile source supplies a complete definition; definitions and their fields
|
||||
are not merged across sources. A higher-precedence source falls back only when
|
||||
@@ -326,33 +327,32 @@ that backend's connection and credential metadata. Profile files do not repeat
|
||||
those values. A configured, application fallback, or in-memory profile with
|
||||
the same profile ID takes precedence.
|
||||
|
||||
| Provider | ID | Backend | Model |
|
||||
| --- | --- | --- | --- |
|
||||
| aion-labs | `aion-2` | `openrouter` | `aion-labs/aion-2.0` |
|
||||
| anthropic | `claude-fable-latest` | `openrouter` | `~anthropic/claude-fable-latest` |
|
||||
| anthropic | `claude-haiku-latest` | `openrouter` | `~anthropic/claude-haiku-latest` |
|
||||
| anthropic | `claude-opus-latest` | `openrouter` | `~anthropic/claude-opus-latest` |
|
||||
| anthropic | `claude-sonnet-latest` | `openrouter` | `~anthropic/claude-sonnet-latest` |
|
||||
| deepseek | `deepseek-3-2` | `openrouter` | `deepseek/deepseek-v3.2` |
|
||||
| deepseek | `deepseek-4-flash` | `openrouter` | `deepseek/deepseek-v4-flash` |
|
||||
| deepseek | `deepseek-4-pro` | `openrouter` | `deepseek/deepseek-v4-pro` |
|
||||
| google | `gemini-2-flash` | `openrouter` | `google/gemini-2.5-flash` |
|
||||
| google | `gemini-2-flash-lite` | `openrouter` | `google/gemini-2.5-flash-lite` |
|
||||
| google | `gemini-2-pro` | `openrouter` | `google/gemini-2.5-pro` |
|
||||
| google | `gemini-3-flash-lite` | `openrouter` | `google/gemini-3.1-flash-lite` |
|
||||
| google | `gemini-flash-latest` | `openrouter` | `~google/gemini-flash-latest` |
|
||||
| google | `gemini-pro-latest` | `openrouter` | `~google/gemini-pro-latest` |
|
||||
| google | `gemma-4-31b` | `openrouter` | `google/gemma-4-31b-it:exacto` |
|
||||
| google | `rakestrawhome-gemma-4-31b` | `rakestrawhome` | `google/gemma-4-31b-it` |
|
||||
| minimax | `minimax-m2` | `openrouter` | `minimax/minimax-m2.5` |
|
||||
| minimax | `minimax-m3` | `openrouter` | `minimax/minimax-m3` |
|
||||
| mistral | `mistral-large-2512` | `openrouter` | `mistralai/mistral-large-2512` |
|
||||
| mistral | `mistral-medium-3-5` | `openrouter` | `mistralai/mistral-medium-3-5` |
|
||||
| mistral | `mistral-small-3` | `openrouter` | `mistralai/mistral-small-3.2-24b-instruct` |
|
||||
| mistral | `mistral-small-4` | `openrouter` | `mistralai/mistral-small-2603` |
|
||||
| nvidia | `nemotron-3-ultra` | `openrouter` | `nvidia/nemotron-3-ultra-550b-a55b` |
|
||||
| openai | `gpt-5-mini` | `openrouter` | `openai/gpt-5.4-mini` |
|
||||
| openai | `gpt-5-nano` | `openrouter` | `openai/gpt-5.4-nano` |
|
||||
Promptkit owns two opt-in profiles. The following definitions are current
|
||||
selections, not automatic defaults:
|
||||
|
||||
| ID | Current definition |
|
||||
| --- | --- |
|
||||
| `promptkit-openrouter-standard` | Inherits `gemini-flash-latest`. |
|
||||
| `promptkit-openrouter-auto` | Selects backend `openrouter` and model `openrouter/auto`. |
|
||||
|
||||
Select either name through an existing request `profile_id` or prompt
|
||||
`default_profile`. Consumers can replace either definition or its inherited
|
||||
base through the ordinary profile-source precedence above. The
|
||||
[release policy](release.md#catalog-profile-compatibility) defines the
|
||||
cross-version guarantee for these names.
|
||||
|
||||
## External Profile Inventories
|
||||
|
||||
The external profile inventory is defined by the selected catalog modules, not
|
||||
duplicated here. Browse the exact data selected by this Promptkit version:
|
||||
|
||||
- [OpenRouter catalog v2.20260922.1 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/tag/v2.20260922.1/catalog/profiles)
|
||||
- [Rakestrawhome catalog v2.20260922.0 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/tag/v2.20260922.0/catalog/profiles)
|
||||
|
||||
External profile entries are volatile across catalog releases. The
|
||||
[release policy](release.md#catalog-profile-compatibility) owns the precise
|
||||
compatibility boundary; a selected module version identifies tested local data,
|
||||
not live provider availability.
|
||||
|
||||
## Schemas
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ stable JSON values.
|
||||
|
||||
## Construction And Pool Lifecycle
|
||||
|
||||
Each root `NewEngine` call obtains a normalized capacity-policy snapshot from
|
||||
Each internal engine construction obtains a normalized capacity-policy snapshot from
|
||||
its immutable backend registry and constructs a new `Manager`. The manager
|
||||
creates one pool for each limited backend ID. It has no package-global mutable
|
||||
state, background workers, shutdown protocol, or persistence, so engines with
|
||||
|
||||
@@ -10,7 +10,7 @@ owns the observable outbound HTTP contract. The
|
||||
[framework format reference](../formats.md) owns the profile and prompt
|
||||
settings consumed by the client.
|
||||
|
||||
The concrete client remains under `internal/llm`. The root engine assembles it
|
||||
The concrete client remains under `internal/llm`. The internal engine assembles it
|
||||
as the default implementation behind Promptkit's public client boundary.
|
||||
|
||||
## Components And Flow
|
||||
|
||||
@@ -11,25 +11,26 @@ contributor workflow and validation.
|
||||
|
||||
| Component | Implemented responsibility | References |
|
||||
| --- | --- | --- |
|
||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, and profile-inspection values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, typed capacity and generation error mapping, engine-local profile-source assembly including application fallbacks, and bounded output-repair assembly. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, and profile-inspection values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go) |
|
||||
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/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 maintained definitions 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/catalog` | Strictly validates imported immutable maintained backend and profile catalog assets before engine assembly uses them. | [Catalog adapter](../../internal/catalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) |
|
||||
| `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, 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/engine` | Assembles external catalogs, Promptkit-owned profiles, repositories, registry, capacity, collaborators, repairer, and the one use-case runner for each engine instance. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [internal runner](runner.md) |
|
||||
| `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/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/promptdef` | Loads strictly decoded, validated prompt definitions from filesystem and `fs.FS` sources, including version selection and contained file-backed message content. | [Framework formats](../formats.md), [prompt-definition repository](../../internal/promptdef/filesystem_repository.go) |
|
||||
| `internal/profile` | Loads strictly decoded, locally validated execution profiles from filesystem and `fs.FS` sources, overlays raw sources with error-preserving fallback, and resolves inherited profiles. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go), [internal sources](sources.md#profiles-and-built-ins) |
|
||||
| `internal/profile` | Owns immutable in-memory and filesystem profile repositories, local profile validation, raw-source overlays with error-preserving fallback, and inherited-profile resolution. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go), [internal sources](sources.md#profiles-and-built-ins) |
|
||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
||||
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
||||
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
||||
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
||||
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
||||
|
||||
The root package assembles these internal components without exposing their
|
||||
The internal engine assembles these internal components without exposing their
|
||||
representations. Consumers depend only on the root facade.
|
||||
|
||||
## Maintenance
|
||||
|
||||
@@ -9,8 +9,8 @@ artifact, rendering, and validation behavior, while the
|
||||
[model-client document](llm.md) owns generation behavior and failure
|
||||
categories.
|
||||
|
||||
The runner remains under `internal/usecase` and is assembled by the root
|
||||
Promptkit engine. Its concrete type is not part of the public API.
|
||||
The runner remains under `internal/usecase` and is assembled by
|
||||
`internal/engine`. Its concrete type is not part of the public API.
|
||||
The [framework format reference](../formats.md) owns prompt, profile, schema,
|
||||
and override semantics consumed by the runner.
|
||||
|
||||
@@ -18,12 +18,12 @@ and override semantics consumed by the runner.
|
||||
|
||||
`Runner` coordinates narrow internal interfaces for prompt definitions,
|
||||
profiles, backend resolution, artifacts, rendering, model generation, and
|
||||
validation. The root engine supplies one immutable registry containing the
|
||||
validation. The internal engine supplies one immutable registry containing the
|
||||
built-in backend and validated consumer additions, one engine-local run
|
||||
admitter, and a model client wrapped by the same capacity manager. Validation
|
||||
plans and provider-facing schema metadata come from the validator's preparation
|
||||
interface.
|
||||
The root engine supplies one default output repairer through the explicit
|
||||
The internal engine supplies one default output repairer through the explicit
|
||||
runner constructor, using the same capacity-wrapped client as initial
|
||||
generation. The no-repair runner constructor remains available for focused
|
||||
internal callers and tests.
|
||||
|
||||
@@ -6,7 +6,7 @@ This document describes Promptkit's implemented internal source, artifact,
|
||||
rendering, and output-validation behavior. The
|
||||
[architecture policy](../policy/architecture.md) owns the library boundary and
|
||||
dependency rules. None of these internal packages is a supported consumer API,
|
||||
and the root engine assembles them behind its public source options and values.
|
||||
and the internal engine assembles them behind the public facade's source options and values.
|
||||
The [framework format reference](../formats.md) owns the exact file fields,
|
||||
validation modes, built-in catalog, and source precedence.
|
||||
|
||||
@@ -45,8 +45,10 @@ duplicate detection, and source containment:
|
||||
|
||||
## Profiles And Built-Ins
|
||||
|
||||
`internal/profile` loads, locally validates, overlays, and resolves execution
|
||||
profiles from an operating-system filesystem or an `fs.FS`. A file contains
|
||||
`internal/profile` owns immutable in-memory and filesystem repositories, local
|
||||
validation, overlays, and resolution for execution profiles. In-memory
|
||||
definitions are normalized and validated once at that repository boundary and
|
||||
are defensively copied on storage and lookup. A file contains
|
||||
exactly one YAML document and its trimmed YAML `id` is its only selection
|
||||
identity; filenames do not confer authority. Each point lookup reads discovered
|
||||
files once for their metadata and reuses the selected file's bytes for strict
|
||||
@@ -67,8 +69,12 @@ repositories described above.
|
||||
`internal/catalog` validates the imported immutable OpenRouter and
|
||||
Rakestrawhome asset modules as one private adapter boundary. It enforces their
|
||||
manifest, layout, profile ownership, inheritance, and secret-safety rules
|
||||
before returning raw catalog sources. Root assembly uses those validated
|
||||
catalogs as the maintained lowest-precedence profile source.
|
||||
before returning raw catalog sources. Internal engine assembly uses those validated
|
||||
catalogs as the lowest-precedence profile source.
|
||||
|
||||
Normalized external catalog profile IDs with the exact `promptkit-` prefix are
|
||||
rejected at this adapter boundary. That reservation applies only to imported
|
||||
catalogs; configured consumer sources retain the ordinary profile-ID rules.
|
||||
|
||||
The overlay repository consults the next repository only when the
|
||||
higher-precedence repository reports that a profile is absent. A reliably
|
||||
@@ -78,12 +84,15 @@ backend registry membership because the available registry belongs to the
|
||||
assembled engine; the runner checks membership during preparation and exact
|
||||
profile inspection.
|
||||
|
||||
The root engine assembles one raw composite catalog in precedence order:
|
||||
The internal engine assembles one raw composite catalog in precedence order:
|
||||
in-memory profiles, one ordinary configured source, an application fallback
|
||||
source, then the maintained external catalog. An explicit file or `fs.FS` profile
|
||||
source replaces `Config.ProfileDir` within the ordinary configured-source
|
||||
category. One outer resolving repository wraps that complete raw catalog, so
|
||||
each base lookup observes the same precedence and shadowing rules.
|
||||
source, Promptkit-owned profiles, then the maintained external catalog. It
|
||||
constructs the owned definitions through the in-memory repository and validates
|
||||
their maintained-only resolution and backend membership before consumer sources
|
||||
are overlaid. An explicit file or `fs.FS` profile source replaces
|
||||
`Config.ProfileDir` within the ordinary configured-source category. One outer
|
||||
resolving repository wraps that complete raw catalog, so each base lookup
|
||||
observes the same precedence and shadowing rules.
|
||||
|
||||
The resolving repository traverses every selected chain afresh, retains no
|
||||
cache, detects cycles, limits a chain to 32 profiles, merges root-to-leaf into a
|
||||
@@ -98,9 +107,10 @@ or schema sources. It does not retain that lookup for a later execution.
|
||||
Prepared execution instead freezes the fully resolved target; a later ordinary
|
||||
operation performs a fresh traversal.
|
||||
|
||||
The maintained external catalog provides every built-in profile and its
|
||||
matching backend definition. Profile loading and overlay behavior are owned by the
|
||||
[profile repository tests](../../internal/profile/repository_test.go), while
|
||||
The imported external catalog provides external profiles and matching backend
|
||||
definitions. The internal engine owns the Promptkit profiles documented in the
|
||||
[framework format reference](../formats.md#built-in-profile-catalog). Profile
|
||||
loading and overlay behavior are owned by the [profile repository tests](../../internal/profile/repository_test.go), while
|
||||
catalog completeness, backend selection, and duplicate IDs are owned by the
|
||||
[catalog adapter tests](../../internal/catalog/catalog_test.go).
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ The implemented internal components consist of:
|
||||
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
||||
helpers for filesystem and `fs.FS` consumers;
|
||||
- `internal/catalog`, which validates immutable external maintained-catalog
|
||||
assets before they are eligible for engine assembly;
|
||||
assets and their reserved profile namespace before they are eligible for
|
||||
engine assembly;
|
||||
- `internal/jsonvalue`, which validates and defensively copies JSON-compatible
|
||||
extra-parameter trees;
|
||||
- `internal/promptdef`, which loads and validates prompt definitions from
|
||||
@@ -46,26 +47,28 @@ The implemented internal components consist of:
|
||||
implements outbound OpenAI-compatible chat requests; and
|
||||
- `internal/usecase`, which coordinates preparation and execution across the
|
||||
internal framework components.
|
||||
- `internal/engine`, which assembles those components into one engine and
|
||||
delegates execution to the use-case runner.
|
||||
|
||||
The `examples/go-library/prepare` and `examples/go-library/run` packages are
|
||||
maintained downstream consumers of the root facade. They do not expose library
|
||||
packages or participate in internal assembly.
|
||||
|
||||
The root facade assembles one immutable backend registry, one capacity manager,
|
||||
The internal engine assembles one immutable backend registry, one capacity manager,
|
||||
the internal repositories, renderer, validator, outbound client, and use-case
|
||||
runner while translating public values and errors at the library boundary. The
|
||||
registry contains validated maintained definitions plus engine-scoped consumer
|
||||
additions.
|
||||
The facade constructs the capacity manager from the registry's immutable
|
||||
runner. The registry contains validated maintained definitions plus engine-scoped
|
||||
consumer additions. The root facade translates public values and errors at the
|
||||
library boundary.
|
||||
The internal engine constructs the capacity manager from the registry's immutable
|
||||
policy snapshot, wraps the selected built-in or injected model client, and
|
||||
supplies bounded admission to the runner. The defaults and renderer depend on
|
||||
the domain model. Prompt-definition and profile repositories use the domain
|
||||
model, file catalog, and YAML decoder. The catalog adapter validates imported
|
||||
immutable maintained data before root assembly supplies it to the profile
|
||||
package. Artifact reading uses the domain
|
||||
model and application-neutral defaults. Validation uses the domain model, file
|
||||
catalog, and JSON Schema implementation. The model client uses the domain
|
||||
model, application-neutral defaults, and an injected or standard-library HTTP
|
||||
immutable maintained data, including its reserved namespace, before internal
|
||||
engine assembly supplies it to the profile package. Artifact reading uses the
|
||||
domain model and application-neutral defaults. Validation uses the domain model,
|
||||
file catalog, and JSON Schema implementation. The model client uses the domain
|
||||
model and application-neutral defaults, and an injected or standard-library HTTP
|
||||
client. The use-case runner depends on the narrow interfaces owned by each
|
||||
internal component, including backend lookup and run admission.
|
||||
|
||||
@@ -127,8 +130,10 @@ consumer contract requires a public package.
|
||||
Each package must have one cohesive responsibility and a clear dependency
|
||||
direction. Internal packages must not expose their types merely to simplify
|
||||
wiring, and the public facade must not leak internal representations through
|
||||
exported signatures. New public packages require a durable consumer need that
|
||||
cannot be served cleanly by the root facade.
|
||||
exported signatures. Production packages under `internal/` must not import the
|
||||
module root; the root facade may depend on internal implementation packages.
|
||||
New public packages require a durable consumer need that cannot be served
|
||||
cleanly by the root facade.
|
||||
|
||||
The [internal component overview](../internal/overview.md) must be updated as
|
||||
packages are implemented or their responsibilities change.
|
||||
|
||||
@@ -16,6 +16,32 @@ changes, and state any action required of consumers.
|
||||
Promptkit releases are source-only. The annotated tag message is the release
|
||||
note; there is no separate hosted release or binary packaging step.
|
||||
|
||||
## Catalog Profile Compatibility
|
||||
|
||||
External catalog profile data may be added, removed, renamed, or modified
|
||||
without deprecation or advance notice. This exception is limited to profile
|
||||
data imported from the maintained catalog modules. It does not weaken ordinary
|
||||
compatibility for Promptkit's public Go API, configuration and format semantics,
|
||||
runtime behavior, catalog-module APIs, manifest schema, backend definitions, or
|
||||
consumer-owned definitions.
|
||||
|
||||
Within the catalog versions selected and tested by a Promptkit release, and
|
||||
unless a consumer overrides the name or its base, Promptkit keeps the literal
|
||||
profile IDs `promptkit-openrouter-standard` and
|
||||
`promptkit-openrouter-auto` available. The names do not promise a permanent
|
||||
model, backend, routing behavior, settings, cost, output, or upstream
|
||||
availability. The [framework format reference](formats.md#built-in-profile-catalog)
|
||||
owns their current definitions.
|
||||
|
||||
`v0.10.0` is the initial minor release for this compatibility transition.
|
||||
Afterward, catalog-only updates and adjustments to the current selections may
|
||||
ship in a patch release when all other compatibility obligations remain intact.
|
||||
External profile removals can require consumer action; they are not automatic
|
||||
migrations.
|
||||
|
||||
Catalog adoption and release validation remain local and offline. They do not
|
||||
probe provider endpoints, request model lists, or make inference calls.
|
||||
|
||||
## Establish The Candidate
|
||||
|
||||
Choose a version that has not been published and export it as
|
||||
@@ -147,11 +173,12 @@ Inspect the complete message and confirm that it accurately records the
|
||||
compatibility impact, public API changes, and required consumer action.
|
||||
|
||||
When a release introduces or updates the maintained external catalogs, state
|
||||
that it selects two independently versioned data dependencies, preserves the
|
||||
public API and configuration, requires no consumer migration, and guarantees
|
||||
only the catalog versions selected and tested by that Promptkit release. Link
|
||||
to the current architecture and format documentation rather than duplicating
|
||||
catalog contracts in the release note.
|
||||
the two independently versioned selected data dependencies and any resulting
|
||||
consumer action. State that external profile additions, removals, renames, and
|
||||
selection changes are covered by the catalog-profile policy, while the ordinary
|
||||
public API and configuration contracts remain in force. Link to the current
|
||||
format and consumer documentation rather than duplicating catalog inventories
|
||||
or contracts in the release note.
|
||||
|
||||
## Create And Inspect The Tag
|
||||
|
||||
|
||||
44
docs/releases/v0.10.0.md
Normal file
44
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Promptkit v0.10.0
|
||||
|
||||
This supplemental guide summarizes the `v0.10.0` changes and migration needs.
|
||||
The annotated tag remains the authoritative release record.
|
||||
|
||||
## Transition Summary
|
||||
|
||||
`v0.10.0` adopts independently versioned OpenRouter and Rakestrawhome catalog
|
||||
modules whose external profile data can change between catalog releases. It
|
||||
also introduces the maintained literal names
|
||||
`promptkit-openrouter-standard` and `promptkit-openrouter-auto`.
|
||||
|
||||
The public package now delegates assembly to an internal engine while retaining
|
||||
the existing Go API. This refactor requires no consumer code changes.
|
||||
|
||||
This release selects OpenRouter `v2.20260922.1` and Rakestrawhome
|
||||
`v2.20260922.0`; their exact inventories are linked from the
|
||||
[format reference](../formats.md#external-profile-inventories).
|
||||
|
||||
The maintained names provide continuity only within the catalog versions
|
||||
selected and tested by a Promptkit release and when consumers do not override
|
||||
the name or its base. Their current selections may change. External catalog
|
||||
profile IDs can be removed, renamed, or modified, so an application that
|
||||
selects one directly may need to update its configuration when upgrading.
|
||||
|
||||
## Consumer Migration
|
||||
|
||||
Before upgrading, review every profile ID used by prompts, requests, and
|
||||
application-owned inheritance. Choose a maintained name, accept an external
|
||||
catalog profile's volatility, or define a complete application-owned profile
|
||||
when the application must control its target and settings. An alias that
|
||||
inherits from an external base is not a frozen copy.
|
||||
|
||||
In particular, the selected OpenRouter catalog removes `deepseek-3-2`,
|
||||
`deepseek-4-flash`, and `deepseek-4-pro`. Consumers using those IDs must choose
|
||||
an available profile or supply their own complete definition; the new
|
||||
`deepseek-flash-latest` and `deepseek-pro-latest` profiles are not automatic
|
||||
aliases and need not preserve the previous settings.
|
||||
|
||||
See the [release policy](../release.md#catalog-profile-compatibility) for the
|
||||
compatibility boundary, the [format reference](../formats.md#built-in-profile-catalog)
|
||||
for current Promptkit-owned definitions and selected external inventories, and
|
||||
the [consumer guide](../consumers/pkg-promptkit.md#choose-a-profile-ownership-model)
|
||||
for configuration choices.
|
||||
245
engine.go
245
engine.go
@@ -6,25 +6,11 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter"
|
||||
rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome"
|
||||
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/catalog"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||
)
|
||||
|
||||
// ErrInvalidConfig identifies invalid engine construction, including missing
|
||||
@@ -91,7 +77,7 @@ var (
|
||||
// model generation. Injected collaborators may still be invoked concurrently
|
||||
// across different backend pools or for unlimited backends.
|
||||
type Engine struct {
|
||||
runner *usecase.Runner
|
||||
internal *internalengine.Engine
|
||||
}
|
||||
|
||||
// Config selects the directory-backed sources and built-in model-client
|
||||
@@ -140,20 +126,7 @@ func (f optionFunc) apply(options *engineOptions) error {
|
||||
}
|
||||
|
||||
type engineOptions struct {
|
||||
llmClient llm.Client
|
||||
artifactReader artifactadapter.Reader
|
||||
promptDefs promptdef.Repository
|
||||
profiles profile.Repository
|
||||
fallbackProfiles profile.Repository
|
||||
memoryProfiles profile.Repository
|
||||
backends []domain.Backend
|
||||
validator validate.Validator
|
||||
promptSource bool
|
||||
profileSource bool
|
||||
fallbackProfileSource bool
|
||||
memorySource bool
|
||||
validatorSource bool
|
||||
artifactSource bool
|
||||
internal internalengine.Options
|
||||
}
|
||||
|
||||
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||
@@ -169,7 +142,7 @@ func WithLLMClient(client LLMClient) Option {
|
||||
if client == nil {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
options.llmClient = publicLLMClientAdapter{client: client}
|
||||
options.internal.SetLLMClient(publicLLMClientAdapter{client: client})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -184,8 +157,7 @@ func WithArtifactReader(reader ArtifactReader) Option {
|
||||
if reader == nil {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
options.artifactReader = publicArtifactReaderAdapter{reader: reader}
|
||||
options.artifactSource = true
|
||||
options.internal.SetArtifactReader(publicArtifactReaderAdapter{reader: reader})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -199,15 +171,7 @@ func WithArtifactReader(reader ArtifactReader) Option {
|
||||
// prompt-source options.
|
||||
func WithPromptFS(fsys fs.FS, root string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
if fsys == nil {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
||||
options.promptSource = true
|
||||
return nil
|
||||
return options.internal.SetPromptFS(fsys, root)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,13 +182,7 @@ func WithPromptFS(fsys fs.FS, root string) Option {
|
||||
// This option replaces Config.PromptDir and earlier prompt-source options.
|
||||
func WithPromptFile(path string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
fsys, root, err := fileSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.promptDefs = promptdef.NewFileRepository(fsys, root, filepath.Dir(path))
|
||||
options.promptSource = true
|
||||
return nil
|
||||
return options.internal.SetPromptFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,15 +196,7 @@ func WithPromptFile(path string) Option {
|
||||
// FS profile-source options, but remains below WithProfiles in precedence.
|
||||
func WithProfileFS(fsys fs.FS, root string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
if fsys == nil {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
options.profiles = profile.NewFSRepository(fsys, root)
|
||||
options.profileSource = true
|
||||
return nil
|
||||
return options.internal.SetProfileFS(fsys, root)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,13 +210,7 @@ func WithProfileFS(fsys fs.FS, root string) Option {
|
||||
// precedence.
|
||||
func WithProfileFile(path string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
fsys, root, err := fileSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.profiles = profile.NewFSRepository(fsys, root)
|
||||
options.profileSource = true
|
||||
return nil
|
||||
return options.internal.SetProfileFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -291,15 +235,7 @@ func WithProfileFile(path string) Option {
|
||||
// failover.
|
||||
func WithFallbackProfileFS(fsys fs.FS, root string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
if fsys == nil {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
options.fallbackProfiles = profile.NewFSRepository(fsys, root)
|
||||
options.fallbackProfileSource = true
|
||||
return nil
|
||||
return options.internal.SetFallbackProfileFS(fsys, root)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -314,13 +250,11 @@ func WithFallbackProfileFS(fsys fs.FS, root string) Option {
|
||||
// earlier in-memory set rather than merging it.
|
||||
func WithProfiles(profiles ...Profile) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
repo, err := newMemoryProfileRepository(profiles)
|
||||
if err != nil {
|
||||
return err
|
||||
definitions := make([]domain.ExecutionProfile, len(profiles))
|
||||
for index, publicProfile := range profiles {
|
||||
definitions[index] = toDomainProfile(publicProfile)
|
||||
}
|
||||
options.memoryProfiles = repo
|
||||
options.memorySource = true
|
||||
return nil
|
||||
return options.internal.SetMemoryProfiles(definitions)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,15 +266,7 @@ func WithProfiles(profiles ...Profile) Option {
|
||||
// replaces Config.SchemaDir and earlier schema-source options.
|
||||
func WithSchemaFS(fsys fs.FS, root string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
if fsys == nil {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return ErrInvalidConfig
|
||||
}
|
||||
options.validator = validate.NewFSValidator(fsys, root)
|
||||
options.validatorSource = true
|
||||
return nil
|
||||
return options.internal.SetSchemaFS(fsys, root)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -351,13 +277,7 @@ func WithSchemaFS(fsys fs.FS, root string) Option {
|
||||
// replaces Config.SchemaDir and earlier schema-source options.
|
||||
func WithSchemaFile(path string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
fsys, root, err := fileSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options.validator = validate.NewFSValidator(fsys, root)
|
||||
options.validatorSource = true
|
||||
return nil
|
||||
return options.internal.SetSchemaFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -384,113 +304,14 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||
}
|
||||
}
|
||||
|
||||
promptDefs := options.promptDefs
|
||||
if !options.promptSource {
|
||||
if strings.TrimSpace(cfg.PromptDir) == "" {
|
||||
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
|
||||
}
|
||||
promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
||||
}
|
||||
|
||||
maintainedCatalogs, err := catalog.Load(
|
||||
catalog.Source{Name: "OpenRouter", ExpectedBackendID: backend.OpenRouterID, FS: openrouter.FS(), Root: openrouter.Root},
|
||||
catalog.Source{Name: "Rakestrawhome", ExpectedBackendID: backend.RakestrawHomeID, FS: rakestrawhome.FS(), Root: rakestrawhome.Root},
|
||||
assembled, err := internalengine.New(
|
||||
internalengine.Config{PromptDir: cfg.PromptDir, ProfileDir: cfg.ProfileDir, SchemaDir: cfg.SchemaDir, Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient},
|
||||
options.internal,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to load maintained catalogs: %v", ErrInvalidConfig, err)
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||
}
|
||||
|
||||
profiles := newProfileRepository(cfg.ProfileDir, options, maintainedCatalogs.Profiles)
|
||||
|
||||
backendRegistry, err := backend.NewRegistry(maintainedCatalogs.Backends, options.backends)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to construct backend registry: %v", ErrInvalidConfig, err)
|
||||
}
|
||||
|
||||
capacityManager, err := capacity.NewManager(backendRegistry.CapacityPolicies())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to construct backend capacity manager: %v", ErrInvalidConfig, err)
|
||||
}
|
||||
|
||||
validator := options.validator
|
||||
if !options.validatorSource {
|
||||
schemaDir := cfg.SchemaDir
|
||||
if strings.TrimSpace(schemaDir) == "" {
|
||||
schemaDir = defaults.SchemaDirDefault
|
||||
}
|
||||
validator = validate.NewStandardValidator(schemaDir)
|
||||
}
|
||||
|
||||
llmClient := options.llmClient
|
||||
if llmClient == nil {
|
||||
var err error
|
||||
llmClient, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
|
||||
Timeout: cfg.Timeout,
|
||||
HTTPClient: cfg.HTTPClient,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||
}
|
||||
}
|
||||
llmClient = capacity.NewClient(capacityManager, llmClient)
|
||||
|
||||
artifacts := options.artifactReader
|
||||
if !options.artifactSource {
|
||||
artifacts = artifactadapter.NewCompositeReader()
|
||||
}
|
||||
|
||||
return &Engine{
|
||||
runner: usecase.NewRunnerWithRepairer(
|
||||
promptDefs,
|
||||
profiles,
|
||||
backendRegistry,
|
||||
artifacts,
|
||||
prompt.NewGoRenderer(),
|
||||
llmClient,
|
||||
validator,
|
||||
usecase.NewDefaultOutputRepairer(llmClient),
|
||||
capacityManager,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newProfileRepository(profileDir string, options engineOptions, maintained profile.Repository) profile.Repository {
|
||||
repository := maintained
|
||||
|
||||
if options.fallbackProfileSource {
|
||||
repository = profile.NewOverlayRepository(options.fallbackProfiles, repository)
|
||||
}
|
||||
|
||||
if options.profileSource {
|
||||
repository = profile.NewOverlayRepository(options.profiles, repository)
|
||||
} else if strings.TrimSpace(profileDir) != "" {
|
||||
repository = profile.NewOverlayRepository(profile.NewFilesystemRepository(profileDir), repository)
|
||||
}
|
||||
|
||||
if options.memorySource {
|
||||
repository = profile.NewOverlayRepository(options.memoryProfiles, repository)
|
||||
}
|
||||
|
||||
return profile.NewResolvingRepository(repository)
|
||||
}
|
||||
|
||||
func fileSource(name string) (fs.FS, string, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, "", ErrInvalidConfig
|
||||
}
|
||||
dir := filepath.Dir(name)
|
||||
base := filepath.Base(name)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return nil, "", ErrInvalidConfig
|
||||
}
|
||||
info, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, name, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, name)
|
||||
}
|
||||
return os.DirFS(dir), filepath.ToSlash(base), nil
|
||||
return &Engine{internal: assembled}, nil
|
||||
}
|
||||
|
||||
// InspectPrompt resolves one explicit prompt definition without selecting a
|
||||
@@ -529,11 +350,11 @@ func (e *Engine) InspectPrompt(
|
||||
promptID string,
|
||||
promptVersion string,
|
||||
) (*PromptInspection, error) {
|
||||
if e == nil || e.runner == nil {
|
||||
if e == nil || e.internal == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
}
|
||||
|
||||
inspection, err := e.runner.InspectPrompt(ctx, promptID, promptVersion)
|
||||
inspection, err := e.internal.InspectPrompt(ctx, promptID, promptVersion)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
@@ -569,11 +390,11 @@ func (e *Engine) InspectPrompt(
|
||||
// profile loading matches ErrProfileLoad while preserving the context error.
|
||||
// InspectProfile returns no partial result on error.
|
||||
func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*ProfileInspection, error) {
|
||||
if e == nil || e.runner == nil {
|
||||
if e == nil || e.internal == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
}
|
||||
|
||||
inspection, err := e.runner.InspectProfile(ctx, profileID)
|
||||
inspection, err := e.internal.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
@@ -600,7 +421,7 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
|
||||
// relationship to ctx.Err is promised. Prepare returns no partial result on
|
||||
// error.
|
||||
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
||||
if e == nil || e.runner == nil {
|
||||
if e == nil || e.internal == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
}
|
||||
|
||||
@@ -609,7 +430,7 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
prepared, err := e.runner.Prepare(ctx, domainReq)
|
||||
prepared, err := e.internal.Prepare(ctx, domainReq)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
@@ -633,7 +454,7 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
||||
// returns no handle on error. A nil Engine returns an error matching
|
||||
// ErrInvalidConfig.
|
||||
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
||||
if e == nil || e.runner == nil {
|
||||
if e == nil || e.internal == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
}
|
||||
|
||||
@@ -642,7 +463,7 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
prepared, err := e.runner.PrepareExecution(ctx, domainReq)
|
||||
prepared, err := e.internal.PrepareExecution(ctx, domainReq)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
@@ -674,7 +495,7 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
|
||||
// behavior. A nil Engine returns ErrInvalidConfig. Run returns no partial
|
||||
// result on error.
|
||||
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if e == nil || e.runner == nil {
|
||||
if e == nil || e.internal == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
}
|
||||
|
||||
@@ -683,7 +504,7 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||
}
|
||||
|
||||
result, err := e.runner.Run(ctx, domainReq)
|
||||
result, err := e.internal.Run(ctx, domainReq)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
@@ -715,7 +536,7 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
// including repair exhaustion, is returned in RunResult, not as an operational
|
||||
// error. An operational error returns no partial RunResult.
|
||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||
if e == nil || e.runner == nil {
|
||||
if e == nil || e.internal == nil {
|
||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||
}
|
||||
|
||||
@@ -723,7 +544,7 @@ func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
|
||||
if prepared != nil {
|
||||
internal = prepared.internal
|
||||
}
|
||||
result, err := e.runner.RunPrepared(ctx, internal)
|
||||
result, err := e.internal.RunPrepared(ctx, internal)
|
||||
if err != nil {
|
||||
return nil, mapPublicError(err)
|
||||
}
|
||||
|
||||
@@ -2546,6 +2546,38 @@ func TestInMemoryProfileExtraParamsAreCopiedAcrossPublicBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithProfilesCopiesDefinitionsWhenApplied(t *testing.T) {
|
||||
nested := map[string]any{"value": "when option is created"}
|
||||
option := promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "profile",
|
||||
Endpoint: "http://copy.example/v1",
|
||||
Model: "copy-model",
|
||||
ExtraParams: map[string]any{"nested": nested},
|
||||
})
|
||||
nested["value"] = "when option is applied"
|
||||
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir}, option)
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
nested["value"] = "after construction"
|
||||
|
||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: frameworkMarkdownSummaryPromptID,
|
||||
ProfileID: "profile",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare with copied profile: %v", err)
|
||||
}
|
||||
if got := prepared.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"]; got != "when option is applied" {
|
||||
t.Fatalf("profile copy value = %q, want value at option application", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithProfilesRejectsInvalidExtraParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
4
go.mod
4
go.mod
@@ -3,8 +3,8 @@ module gitea.maximumdirect.net/eric/promptkit
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter v1.0.0
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome v1.0.0
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
8
go.sum
8
go.sum
@@ -1,7 +1,7 @@
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter v1.0.0 h1:lc062euk2qseO//D762i3JaFyulDNML3eQQX7DkYTho=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter v1.0.0/go.mod h1:AIa7kAu2mfrRQgcspe4L+DW51WqgnALQT60lqkEywJI=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome v1.0.0 h1:j9YY7wsTVjzke2kHH4YAzpU0oUpM+x+nXwl1IeS+2eg=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome v1.0.0/go.mod h1:4RNS+LILDg4JbS4Ts9Lwy1C92wauXJIbeQaalps4Koo=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1 h1:S2/Q/JDE+m8Ca3pDhAjVbfYrdlEhs9blj0STgNopHo0=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1/go.mod h1:a3VBbh9oaGXD6ruxhtwHj+AWW6DY/SSyby1QtMmYSUo=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 h1:Y0EXc3eh12eKW2NrOviVqdk3oBo6UUjopt1YAa1B3Oc=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0/go.mod h1:mtgD1E0SGm0e/I8Ffhz+AYX7GN7RKUWJXehNFIKHs4Y=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
|
||||
@@ -64,6 +64,9 @@ func Load(sources ...Source) (Set, error) {
|
||||
}
|
||||
resolvingRepository := profile.NewResolvingRepository(repository)
|
||||
for _, entry := range metadata {
|
||||
if strings.HasPrefix(entry.ID, "promptkit-") {
|
||||
return Set{}, fmt.Errorf("catalog %s: %s uses the reserved promptkit- profile ID prefix", source.Name, entry.Path)
|
||||
}
|
||||
if profileIDs[entry.ID] {
|
||||
return Set{}, fmt.Errorf("catalog %s: %s duplicates an earlier profile ID", source.Name, entry.Path)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter"
|
||||
rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome"
|
||||
openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2"
|
||||
rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2"
|
||||
)
|
||||
|
||||
func TestLoadPublishedCatalogsMatchCompatibilityFixture(t *testing.T) {
|
||||
func TestLoadPublishedCatalogs(t *testing.T) {
|
||||
loaded, err := Load(
|
||||
Source{Name: "OpenRouter", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: openrouter.Root},
|
||||
Source{Name: "Rakestrawhome", ExpectedBackendID: "rakestrawhome", FS: rakestrawhome.FS(), Root: rakestrawhome.Root},
|
||||
@@ -26,22 +21,48 @@ func TestLoadPublishedCatalogsMatchCompatibilityFixture(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load published catalogs: %v", err)
|
||||
}
|
||||
expected := loadCompatibilityFixture(t)
|
||||
expectedIDs := fixtureProfileIDs(t, expected)
|
||||
if !reflect.DeepEqual(loaded.profileIDs, expectedIDs) {
|
||||
t.Fatalf("published profile IDs differ from compatibility fixture: got %q, want %q", loaded.profileIDs, expectedIDs)
|
||||
if loaded.Profiles == nil || len(loaded.profileIDs) == 0 {
|
||||
t.Fatalf("published catalogs have no usable profiles: %#v", loaded)
|
||||
}
|
||||
actual := catalogValue(t, loaded)
|
||||
actualJSON, err := json.Marshal(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("encode loaded catalogs: %v", err)
|
||||
if _, err := loaded.Profiles.GetProfile(context.Background(), "gemini-flash-latest"); err != nil {
|
||||
t.Fatalf("load maintained standard-profile base: %v", err)
|
||||
}
|
||||
expectedJSON, err := json.Marshal(expected)
|
||||
if err != nil {
|
||||
t.Fatalf("encode compatibility fixture: %v", err)
|
||||
|
||||
expectedBackends := map[string]struct {
|
||||
endpoint string
|
||||
apiKeyEnv string
|
||||
concurrencyLimit int
|
||||
queueCapacity int
|
||||
}{
|
||||
"openrouter": {
|
||||
endpoint: "https://openrouter.ai/api/v1",
|
||||
apiKeyEnv: "OPENROUTER_API_KEY",
|
||||
concurrencyLimit: 16,
|
||||
queueCapacity: 1024,
|
||||
},
|
||||
"rakestrawhome": {
|
||||
endpoint: "https://inference.ai.rakestrawhome.com/v1",
|
||||
apiKeyEnv: "RAKESTRAWHOME_INFERENCE_API_KEY",
|
||||
concurrencyLimit: 4,
|
||||
queueCapacity: 1024,
|
||||
},
|
||||
}
|
||||
if !bytes.Equal(actualJSON, expectedJSON) {
|
||||
t.Fatalf("published catalogs differ from compatibility fixture: got %#v, want %#v", actual, expected)
|
||||
if len(loaded.Backends) != len(expectedBackends) {
|
||||
t.Fatalf("published backend count = %d, want %d", len(loaded.Backends), len(expectedBackends))
|
||||
}
|
||||
for _, definition := range loaded.Backends {
|
||||
expected, found := expectedBackends[definition.ID]
|
||||
if !found {
|
||||
t.Fatalf("unexpected published backend %q", definition.ID)
|
||||
}
|
||||
if definition.Endpoint != expected.endpoint ||
|
||||
definition.APIKeyEnv != expected.apiKeyEnv ||
|
||||
definition.ConcurrencyLimit != expected.concurrencyLimit ||
|
||||
definition.QueueCapacity != expected.queueCapacity ||
|
||||
!definition.QueueCapacitySet ||
|
||||
definition.ExtraParams != nil {
|
||||
t.Fatalf("unexpected published backend definition: %#v", definition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +214,39 @@ func TestLoadRejectsInvalidCatalogProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReservesPromptkitProfilePrefixForCatalogs(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
profileID string
|
||||
wantError bool
|
||||
}{
|
||||
"maintained name": {
|
||||
profileID: "promptkit-openrouter-standard",
|
||||
wantError: true,
|
||||
},
|
||||
"other reserved name": {
|
||||
profileID: "promptkit-example",
|
||||
wantError: true,
|
||||
},
|
||||
"ordinary name": {
|
||||
profileID: "ordinary-profile",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fsys := validCatalogFS("one")
|
||||
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile(test.profileID, "one"))
|
||||
|
||||
_, err := Load(testSource("one", fsys))
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("Load() error = %v, want error %t", err, test.wantError)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "catalog one: one-profile.yml") {
|
||||
t.Fatalf("Load() error = %v, want catalog source and path context", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsCrossCatalogConflicts(t *testing.T) {
|
||||
t.Run("duplicate backend", func(t *testing.T) {
|
||||
second := catalogFS("one", map[string]string{
|
||||
@@ -265,68 +319,6 @@ func TestLoadReturnsDefensiveCatalogValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func loadCompatibilityFixture(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "builtin-catalog-v1.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func fixtureProfileIDs(t *testing.T, fixture map[string]any) []string {
|
||||
t.Helper()
|
||||
profiles, ok := fixture["profiles"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("compatibility fixture profiles are malformed")
|
||||
}
|
||||
ids := make([]string, 0, len(profiles))
|
||||
for _, entry := range profiles {
|
||||
profileValue, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("compatibility fixture profile is malformed")
|
||||
}
|
||||
id, ok := profileValue["id"].(string)
|
||||
if !ok {
|
||||
t.Fatal("compatibility fixture profile ID is malformed")
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func catalogValue(t *testing.T, loaded Set) map[string]any {
|
||||
t.Helper()
|
||||
backends := make([]any, 0, len(loaded.Backends))
|
||||
for _, backend := range loaded.Backends {
|
||||
backends = append(backends, map[string]any{"id": backend.ID, "endpoint": backend.Endpoint, "api_key_env": backend.APIKeyEnv, "extra_params": interfaceValue(backend.ExtraParams), "concurrency_limit": backend.ConcurrencyLimit, "queue_capacity": backend.QueueCapacity, "queue_capacity_set": backend.QueueCapacitySet})
|
||||
}
|
||||
sort.Slice(backends, func(left, right int) bool {
|
||||
return backends[left].(map[string]any)["id"].(string) < backends[right].(map[string]any)["id"].(string)
|
||||
})
|
||||
actualProfiles := make([]any, 0, len(loaded.profileIDs))
|
||||
for _, id := range loaded.profileIDs {
|
||||
profile, err := loaded.Profiles.GetProfile(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("load profile %q: %v", id, err)
|
||||
}
|
||||
actualProfiles = append(actualProfiles, map[string]any{"id": profile.ID, "base_profile": profile.BaseProfileID, "backend": profile.BackendID, "endpoint": profile.Endpoint, "model": profile.Model, "temperature": profile.Temperature, "max_tokens": profile.MaxTokens, "top_p": profile.TopP, "timeout_seconds": profile.TimeoutSeconds, "service_tier": profile.ServiceTier, "reasoning_effort": profile.ReasoningEffort, "api_key_env": profile.APIKeyEnv, "api_key_required": profile.APIKeyRequired, "extra_params": interfaceValue(profile.ExtraParams)})
|
||||
}
|
||||
return map[string]any{"backends": backends, "profiles": actualProfiles}
|
||||
}
|
||||
|
||||
func interfaceValue(value map[string]any) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func testSource(id string, fsys fs.FS) Source {
|
||||
return Source{Name: id, ExpectedBackendID: id, FS: fsys, Root: "catalog"}
|
||||
}
|
||||
|
||||
277
internal/engine/engine.go
Normal file
277
internal/engine/engine.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Package engine assembles Promptkit's internal execution components.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2"
|
||||
rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/catalog"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||
)
|
||||
|
||||
// Config carries construction settings selected by the public facade.
|
||||
type Config struct {
|
||||
PromptDir string
|
||||
ProfileDir string
|
||||
SchemaDir string
|
||||
Timeout time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Options collects construction choices from the public facade.
|
||||
type Options struct {
|
||||
llmClient llm.Client
|
||||
artifactReader artifact.Reader
|
||||
promptDefinitions promptdef.Repository
|
||||
profiles profile.Repository
|
||||
fallbackProfiles profile.Repository
|
||||
memoryProfiles profile.Repository
|
||||
backends []domain.Backend
|
||||
validator validate.Validator
|
||||
promptSource bool
|
||||
profileSource bool
|
||||
fallbackProfileSource bool
|
||||
memorySource bool
|
||||
validatorSource bool
|
||||
artifactSource bool
|
||||
}
|
||||
|
||||
// SetLLMClient selects the client collaborator for one engine.
|
||||
func (o *Options) SetLLMClient(client llm.Client) { o.llmClient = client }
|
||||
|
||||
// SetArtifactReader selects the artifact collaborator for one engine.
|
||||
func (o *Options) SetArtifactReader(reader artifact.Reader) {
|
||||
o.artifactReader = reader
|
||||
o.artifactSource = true
|
||||
}
|
||||
|
||||
// SetPromptFS selects a prompt source rooted in fsys.
|
||||
func (o *Options) SetPromptFS(fsys fs.FS, root string) error {
|
||||
if err := validateSourceFS(fsys, root); err != nil {
|
||||
return err
|
||||
}
|
||||
o.promptDefinitions = promptdef.NewFSRepository(fsys, root)
|
||||
o.promptSource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPromptFile selects a single prompt file.
|
||||
func (o *Options) SetPromptFile(path string) error {
|
||||
fsys, root, directory, err := fileSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.promptDefinitions = promptdef.NewFileRepository(fsys, root, directory)
|
||||
o.promptSource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProfileFS selects an ordinary profile source rooted in fsys.
|
||||
func (o *Options) SetProfileFS(fsys fs.FS, root string) error {
|
||||
if err := validateSourceFS(fsys, root); err != nil {
|
||||
return err
|
||||
}
|
||||
o.profiles = profile.NewFSRepository(fsys, root)
|
||||
o.profileSource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProfileFile selects a single ordinary profile file.
|
||||
func (o *Options) SetProfileFile(path string) error {
|
||||
fsys, root, _, err := fileSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.profiles = profile.NewFSRepository(fsys, root)
|
||||
o.profileSource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetFallbackProfileFS selects a fallback profile source rooted in fsys.
|
||||
func (o *Options) SetFallbackProfileFS(fsys fs.FS, root string) error {
|
||||
if err := validateSourceFS(fsys, root); err != nil {
|
||||
return err
|
||||
}
|
||||
o.fallbackProfiles = profile.NewFSRepository(fsys, root)
|
||||
o.fallbackProfileSource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMemoryProfiles validates and stores application-owned profile definitions.
|
||||
func (o *Options) SetMemoryProfiles(definitions []domain.ExecutionProfile) error {
|
||||
repository, err := profile.NewMemoryRepository(definitions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.memoryProfiles = repository
|
||||
o.memorySource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddBackend appends one backend registration.
|
||||
func (o *Options) AddBackend(backend domain.Backend) { o.backends = append(o.backends, backend) }
|
||||
|
||||
// SetSchemaFS selects a schema source rooted in fsys.
|
||||
func (o *Options) SetSchemaFS(fsys fs.FS, root string) error {
|
||||
if err := validateSourceFS(fsys, root); err != nil {
|
||||
return err
|
||||
}
|
||||
o.validator = validate.NewFSValidator(fsys, root)
|
||||
o.validatorSource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSchemaFile selects a single schema file.
|
||||
func (o *Options) SetSchemaFile(path string) error {
|
||||
fsys, root, _, err := fileSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.validator = validate.NewFSValidator(fsys, root)
|
||||
o.validatorSource = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Engine forwards domain operations to the single assembled use-case runner.
|
||||
type Engine struct {
|
||||
runner *usecase.Runner
|
||||
}
|
||||
|
||||
// New assembles an engine from facade-selected configuration and collaborators.
|
||||
func New(cfg Config, options Options) (*Engine, error) {
|
||||
promptDefinitions := options.promptDefinitions
|
||||
if !options.promptSource {
|
||||
if strings.TrimSpace(cfg.PromptDir) == "" {
|
||||
return nil, fmt.Errorf("prompt directory is required")
|
||||
}
|
||||
promptDefinitions = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
||||
}
|
||||
|
||||
maintained, err := catalog.Load(
|
||||
catalog.Source{Name: "OpenRouter", ExpectedBackendID: backend.OpenRouterID, FS: openrouter.FS(), Root: openrouter.Root},
|
||||
catalog.Source{Name: "Rakestrawhome", ExpectedBackendID: backend.RakestrawHomeID, FS: rakestrawhome.FS(), Root: rakestrawhome.Root},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load maintained catalogs: %w", err)
|
||||
}
|
||||
|
||||
maintainedProfiles, err := newMaintainedProfileRepository(maintained.Profiles, maintained.Backends)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to construct Promptkit-maintained profiles: %w", err)
|
||||
}
|
||||
|
||||
profiles := newProfileRepository(cfg.ProfileDir, options, maintainedProfiles)
|
||||
registry, err := backend.NewRegistry(maintained.Backends, options.backends)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to construct backend registry: %w", err)
|
||||
}
|
||||
manager, err := capacity.NewManager(registry.CapacityPolicies())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to construct backend capacity manager: %w", err)
|
||||
}
|
||||
|
||||
validator := options.validator
|
||||
if !options.validatorSource {
|
||||
schemaDir := cfg.SchemaDir
|
||||
if strings.TrimSpace(schemaDir) == "" {
|
||||
schemaDir = defaults.SchemaDirDefault
|
||||
}
|
||||
validator = validate.NewStandardValidator(schemaDir)
|
||||
}
|
||||
client := options.llmClient
|
||||
if client == nil {
|
||||
client, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
client = capacity.NewClient(manager, client)
|
||||
reader := options.artifactReader
|
||||
if !options.artifactSource {
|
||||
reader = artifact.NewCompositeReader()
|
||||
}
|
||||
|
||||
return &Engine{runner: usecase.NewRunnerWithRepairer(promptDefinitions, profiles, registry, reader, prompt.NewGoRenderer(), client, validator, usecase.NewDefaultOutputRepairer(client), manager)}, nil
|
||||
}
|
||||
|
||||
func newProfileRepository(profileDir string, options Options, maintained profile.Repository) profile.Repository {
|
||||
repository := maintained
|
||||
if options.fallbackProfileSource {
|
||||
repository = profile.NewOverlayRepository(options.fallbackProfiles, repository)
|
||||
}
|
||||
if options.profileSource {
|
||||
repository = profile.NewOverlayRepository(options.profiles, repository)
|
||||
} else if strings.TrimSpace(profileDir) != "" {
|
||||
repository = profile.NewOverlayRepository(profile.NewFilesystemRepository(profileDir), repository)
|
||||
}
|
||||
if options.memorySource {
|
||||
repository = profile.NewOverlayRepository(options.memoryProfiles, repository)
|
||||
}
|
||||
return profile.NewResolvingRepository(repository)
|
||||
}
|
||||
|
||||
func validateSourceFS(fsys fs.FS, root string) error {
|
||||
if fsys == nil {
|
||||
return fmt.Errorf("source filesystem is nil")
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return fmt.Errorf("source root is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileSource(name string) (fs.FS, string, string, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, "", "", fmt.Errorf("source path is required")
|
||||
}
|
||||
directory := filepath.Dir(name)
|
||||
base := filepath.Base(name)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return nil, "", "", fmt.Errorf("source path must name a file")
|
||||
}
|
||||
info, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to access source file %q: %w", name, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", "", fmt.Errorf("source path %q must be a file", name)
|
||||
}
|
||||
return os.DirFS(directory), filepath.ToSlash(base), directory, nil
|
||||
}
|
||||
|
||||
func (e *Engine) InspectPrompt(ctx context.Context, id, version string) (*domain.PromptInspection, error) {
|
||||
return e.runner.InspectPrompt(ctx, id, version)
|
||||
}
|
||||
func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) {
|
||||
return e.runner.InspectProfile(ctx, id)
|
||||
}
|
||||
func (e *Engine) Prepare(ctx context.Context, request domain.RunRequest) (*domain.PreparedRun, error) {
|
||||
return e.runner.Prepare(ctx, request)
|
||||
}
|
||||
func (e *Engine) PrepareExecution(ctx context.Context, request domain.RunRequest) (*usecase.PreparedExecution, error) {
|
||||
return e.runner.PrepareExecution(ctx, request)
|
||||
}
|
||||
func (e *Engine) Run(ctx context.Context, request domain.RunRequest) (*domain.RunResult, error) {
|
||||
return e.runner.Run(ctx, request)
|
||||
}
|
||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *usecase.PreparedExecution) (*domain.RunResult, error) {
|
||||
return e.runner.RunPrepared(ctx, prepared)
|
||||
}
|
||||
50
internal/engine/maintained_profiles.go
Normal file
50
internal/engine/maintained_profiles.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
)
|
||||
|
||||
func maintainedProfileDefinitions() []domain.ExecutionProfile {
|
||||
return []domain.ExecutionProfile{
|
||||
{
|
||||
ID: "promptkit-openrouter-standard",
|
||||
BaseProfileID: "gemini-flash-latest",
|
||||
},
|
||||
{
|
||||
ID: "promptkit-openrouter-auto",
|
||||
BackendID: backend.OpenRouterID,
|
||||
Model: "openrouter/auto",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newMaintainedProfileRepository(catalogProfiles profile.Repository, maintainedBackends []domain.Backend) (profile.Repository, error) {
|
||||
definitions := maintainedProfileDefinitions()
|
||||
owned, err := profile.NewMemoryRepository(definitions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Promptkit-maintained profiles: %w", err)
|
||||
}
|
||||
|
||||
combined := profile.NewOverlayRepository(owned, catalogProfiles)
|
||||
resolved := profile.NewResolvingRepository(combined)
|
||||
registry, err := backend.NewRegistry(maintainedBackends, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("construct maintained backend registry: %w", err)
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
effective, err := resolved.GetProfile(context.Background(), definition.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve Promptkit-maintained profile %q: %w", definition.ID, err)
|
||||
}
|
||||
if _, err := registry.GetBackend(effective.BackendID); err != nil {
|
||||
return nil, fmt.Errorf("Promptkit-maintained profile %q selects unavailable backend %q: %w", definition.ID, effective.BackendID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return combined, nil
|
||||
}
|
||||
78
internal/engine/maintained_profiles_test.go
Normal file
78
internal/engine/maintained_profiles_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
)
|
||||
|
||||
func TestNewMaintainedProfileRepository(t *testing.T) {
|
||||
newCatalogProfiles := func(t *testing.T, definitions []domain.ExecutionProfile) profile.Repository {
|
||||
t.Helper()
|
||||
repository, err := profile.NewMemoryRepository(definitions)
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog profiles: %v", err)
|
||||
}
|
||||
return repository
|
||||
}
|
||||
newBackend := func(id string) domain.Backend {
|
||||
return domain.Backend{ID: id, Endpoint: "https://" + id + ".example/v1", APIKeyEnv: "TEST_API_KEY"}
|
||||
}
|
||||
|
||||
t.Run("resolves inherited and direct definitions", func(t *testing.T) {
|
||||
repository, err := newMaintainedProfileRepository(
|
||||
newCatalogProfiles(t, []domain.ExecutionProfile{{
|
||||
ID: "gemini-flash-latest",
|
||||
BackendID: backend.OpenRouterID,
|
||||
Model: "catalog-selected-model",
|
||||
}}),
|
||||
[]domain.Backend{newBackend(backend.OpenRouterID)},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create maintained profiles: %v", err)
|
||||
}
|
||||
|
||||
resolved := profile.NewResolvingRepository(repository)
|
||||
standard, err := resolved.GetProfile(context.Background(), "promptkit-openrouter-standard")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve inherited profile: %v", err)
|
||||
}
|
||||
if standard.BackendID != backend.OpenRouterID || standard.Model != "catalog-selected-model" {
|
||||
t.Fatalf("unexpected inherited profile: %#v", standard)
|
||||
}
|
||||
|
||||
auto, err := resolved.GetProfile(context.Background(), "promptkit-openrouter-auto")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve direct profile: %v", err)
|
||||
}
|
||||
definitions := maintainedProfileDefinitions()
|
||||
if auto.BackendID != definitions[1].BackendID || auto.Model != definitions[1].Model {
|
||||
t.Fatalf("unexpected direct profile: %#v", auto)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a missing inherited base", func(t *testing.T) {
|
||||
if _, err := newMaintainedProfileRepository(
|
||||
newCatalogProfiles(t, nil),
|
||||
[]domain.Backend{newBackend(backend.OpenRouterID)},
|
||||
); err == nil {
|
||||
t.Fatal("expected missing base error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects an unavailable effective backend", func(t *testing.T) {
|
||||
if _, err := newMaintainedProfileRepository(
|
||||
newCatalogProfiles(t, []domain.ExecutionProfile{{
|
||||
ID: "gemini-flash-latest",
|
||||
BackendID: "other",
|
||||
Model: "catalog-selected-model",
|
||||
}}),
|
||||
[]domain.Backend{newBackend("other")},
|
||||
); err == nil {
|
||||
t.Fatal("expected unavailable backend error")
|
||||
}
|
||||
})
|
||||
}
|
||||
50
internal/profile/memory_repository.go
Normal file
50
internal/profile/memory_repository.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
)
|
||||
|
||||
// NewMemoryRepository constructs an immutable repository from profile
|
||||
// definitions supplied by an internal caller.
|
||||
func NewMemoryRepository(definitions []domain.ExecutionProfile) (Repository, error) {
|
||||
repository := &memoryRepository{profiles: make(map[string]domain.ExecutionProfile, len(definitions))}
|
||||
for _, definition := range definitions {
|
||||
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition.ExtraParams = extraParams
|
||||
if err := NormalizeAndValidateDefinition(&definition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := repository.profiles[definition.ID]; exists {
|
||||
return nil, fmt.Errorf("duplicate profile id %q", definition.ID)
|
||||
}
|
||||
repository.profiles[definition.ID] = definition
|
||||
}
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
type memoryRepository struct {
|
||||
profiles map[string]domain.ExecutionProfile
|
||||
}
|
||||
|
||||
func (r *memoryRepository) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||
if r == nil {
|
||||
return nil, ErrProfileNotFound
|
||||
}
|
||||
definition, found := r.profiles[id]
|
||||
if !found {
|
||||
return nil, ErrProfileNotFound
|
||||
}
|
||||
extraParams, err := jsonvalue.CopyMap(definition.ExtraParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy memory profile %q: %w", definition.ID, err)
|
||||
}
|
||||
definition.ExtraParams = extraParams
|
||||
return &definition, nil
|
||||
}
|
||||
91
internal/profile/memory_repository_test.go
Normal file
91
internal/profile/memory_repository_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
)
|
||||
|
||||
func TestMemoryRepositoryOwnsNormalizedDefinitions(t *testing.T) {
|
||||
nested := map[string]any{"value": "original"}
|
||||
definitions := []domain.ExecutionProfile{{
|
||||
ID: " profile ",
|
||||
BackendID: " backend ",
|
||||
Endpoint: " https://example.test/v1/ ",
|
||||
Model: "model",
|
||||
ExtraParams: map[string]any{"nested": nested},
|
||||
}}
|
||||
|
||||
repository, err := NewMemoryRepository(definitions)
|
||||
if err != nil {
|
||||
t.Fatalf("construct repository: %v", err)
|
||||
}
|
||||
nested["value"] = "mutated input"
|
||||
|
||||
first, err := repository.GetProfile(context.Background(), "profile")
|
||||
if err != nil {
|
||||
t.Fatalf("load profile: %v", err)
|
||||
}
|
||||
if first.ID != "profile" || first.BackendID != "backend" || first.Endpoint != "https://example.test/v1/" {
|
||||
t.Fatalf("normalized definition = %#v", first)
|
||||
}
|
||||
if first.ExtraParams["nested"].(map[string]any)["value"] != "original" {
|
||||
t.Fatalf("repository retained input mutation: %#v", first.ExtraParams)
|
||||
}
|
||||
first.ExtraParams["nested"].(map[string]any)["value"] = "mutated result"
|
||||
|
||||
second, err := repository.GetProfile(context.Background(), "profile")
|
||||
if err != nil {
|
||||
t.Fatalf("reload profile: %v", err)
|
||||
}
|
||||
if second.ExtraParams["nested"].(map[string]any)["value"] != "original" {
|
||||
t.Fatalf("repository retained result mutation: %#v", second.ExtraParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepositoryPreservesInheritanceAndRejectsInvalidDefinitions(t *testing.T) {
|
||||
derived, err := NewMemoryRepository([]domain.ExecutionProfile{{
|
||||
ID: "derived",
|
||||
BaseProfileID: "base",
|
||||
ReasoningEffort: "high",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("construct derived repository: %v", err)
|
||||
}
|
||||
profile, err := derived.GetProfile(context.Background(), "derived")
|
||||
if err != nil {
|
||||
t.Fatalf("load derived profile: %v", err)
|
||||
}
|
||||
if profile.BaseProfileID != "base" || profile.ReasoningEffort != "high" {
|
||||
t.Fatalf("derived profile = %#v", profile)
|
||||
}
|
||||
if _, err := derived.GetProfile(context.Background(), "missing"); !errors.Is(err, ErrProfileNotFound) {
|
||||
t.Fatalf("missing profile error = %v, want ErrProfileNotFound", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
definitions []domain.ExecutionProfile
|
||||
}{
|
||||
{
|
||||
name: "normalized duplicate IDs",
|
||||
definitions: []domain.ExecutionProfile{
|
||||
{ID: " duplicate ", Endpoint: "https://one.example/v1", Model: "one"},
|
||||
{ID: "duplicate", Endpoint: "https://two.example/v1", Model: "two"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid extra parameters",
|
||||
definitions: []domain.ExecutionProfile{{ID: "invalid", Endpoint: "https://example.test/v1", Model: "model", ExtraParams: map[string]any{"invalid": func() {}}}},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := NewMemoryRepository(test.definitions); err == nil {
|
||||
t.Fatal("expected repository construction error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestProfileInheritanceBuiltInAliasWorkflow(t *testing.T) {
|
||||
SchemaDir: frameworkSchemaDir,
|
||||
}, promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "weather-light",
|
||||
BaseProfileID: "deepseek-4-flash",
|
||||
BaseProfileID: "promptkit-openrouter-standard",
|
||||
ReasoningEffort: "high",
|
||||
TimeoutSeconds: 120,
|
||||
}))
|
||||
@@ -26,7 +26,7 @@ func TestProfileInheritanceBuiltInAliasWorkflow(t *testing.T) {
|
||||
t.Fatalf("construct alias engine: %v", err)
|
||||
}
|
||||
|
||||
base, err := engine.InspectProfile(context.Background(), "deepseek-4-flash")
|
||||
base, err := engine.InspectProfile(context.Background(), "promptkit-openrouter-standard")
|
||||
if err != nil {
|
||||
t.Fatalf("inspect base: %v", err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestProfileInheritanceYAMLAliasOfBuiltIn(t *testing.T) {
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||
promptkit.WithPromptFS(fstest.MapFS{}, "."),
|
||||
promptkit.WithProfileFS(fstest.MapFS{
|
||||
"alias.yaml": &fstest.MapFile{Data: []byte("id: yaml-alias\nbase_profile: deepseek-4-flash\n")},
|
||||
"alias.yaml": &fstest.MapFile{Data: []byte("id: yaml-alias\nbase_profile: promptkit-openrouter-standard\n")},
|
||||
}, "."),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
50
profiles.go
50
profiles.go
@@ -1,12 +1,7 @@
|
||||
package promptkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||
)
|
||||
|
||||
// OpenAICompatibleProfile returns an in-memory Profile for an OpenAI-compatible
|
||||
@@ -50,43 +45,8 @@ func copyShallowAnyMap(src map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
type memoryProfileRepository struct {
|
||||
profiles map[string]domain.ExecutionProfile
|
||||
}
|
||||
|
||||
func newMemoryProfileRepository(profiles []Profile) (*memoryProfileRepository, error) {
|
||||
repo := &memoryProfileRepository{profiles: make(map[string]domain.ExecutionProfile, len(profiles))}
|
||||
for _, publicProfile := range profiles {
|
||||
prof, err := toDomainProfile(publicProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := repo.profiles[prof.ID]; exists {
|
||||
return nil, fmt.Errorf("duplicate profile id %q", prof.ID)
|
||||
}
|
||||
repo.profiles[prof.ID] = prof
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func (r *memoryProfileRepository) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||
if r == nil {
|
||||
return nil, profile.ErrProfileNotFound
|
||||
}
|
||||
prof, ok := r.profiles[id]
|
||||
if !ok {
|
||||
return nil, profile.ErrProfileNotFound
|
||||
}
|
||||
prof.ExtraParams = copyAnyMap(prof.ExtraParams)
|
||||
return &prof, nil
|
||||
}
|
||||
|
||||
func toDomainProfile(publicProfile Profile) (domain.ExecutionProfile, error) {
|
||||
extraParams, err := jsonvalue.CopyMap(publicProfile.ExtraParams)
|
||||
if err != nil {
|
||||
return domain.ExecutionProfile{}, err
|
||||
}
|
||||
prof := domain.ExecutionProfile{
|
||||
func toDomainProfile(publicProfile Profile) domain.ExecutionProfile {
|
||||
return domain.ExecutionProfile{
|
||||
ID: publicProfile.ID,
|
||||
BaseProfileID: publicProfile.BaseProfileID,
|
||||
BackendID: publicProfile.BackendID,
|
||||
@@ -99,10 +59,6 @@ func toDomainProfile(publicProfile Profile) (domain.ExecutionProfile, error) {
|
||||
ServiceTier: publicProfile.ServiceTier,
|
||||
ReasoningEffort: publicProfile.ReasoningEffort,
|
||||
APIKeyRequired: publicProfile.APIKeyRequired,
|
||||
ExtraParams: extraParams,
|
||||
ExtraParams: publicProfile.ExtraParams,
|
||||
}
|
||||
if err := profile.NormalizeAndValidateDefinition(&prof); err != nil {
|
||||
return domain.ExecutionProfile{}, err
|
||||
}
|
||||
return prof, nil
|
||||
}
|
||||
|
||||
397
public_boundary_test.go
Normal file
397
public_boundary_test.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/importer"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublicFacadeDoesNotExposeInternalTypes(t *testing.T) {
|
||||
metadata := listedPackages(t, "-export", ".")
|
||||
if len(metadata) != 1 {
|
||||
t.Fatalf("root package metadata count = %d, want one", len(metadata))
|
||||
}
|
||||
|
||||
pkg := importCompiledPackage(t, metadata[0])
|
||||
violations := publicSurfaceViolations(pkg, metadata[0].ImportPath)
|
||||
if len(violations) > 0 {
|
||||
t.Fatalf("public facade exposes internal implementation:\n%s", strings.Join(violations, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalProductionPackagesDoNotImportFacade(t *testing.T) {
|
||||
root := listedPackages(t, ".")
|
||||
if len(root) != 1 {
|
||||
t.Fatalf("root package metadata count = %d, want one", len(root))
|
||||
}
|
||||
|
||||
var violations []string
|
||||
for _, pkg := range listedPackages(t, "./internal/...") {
|
||||
for _, imported := range pkg.Imports {
|
||||
if imported == root[0].ImportPath {
|
||||
violations = append(violations, pkg.ImportPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(violations)
|
||||
if len(violations) > 0 {
|
||||
t.Fatalf("internal production packages import the public facade:\n%s", strings.Join(violations, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicBoundaryCheckerFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
wantReject bool
|
||||
}{
|
||||
{
|
||||
name: "private internal state and sealed option are allowed",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type privateState struct { value hidden.Value }
|
||||
type Public struct { state privateState }
|
||||
type Option interface { apply(*privateState) }
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "returned private type method is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type result struct{}
|
||||
func (result) Leak() hidden.Value { return hidden.Value{} }
|
||||
func New() result { return result{} }
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "reachable private pointer method is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type result struct{}
|
||||
func (*result) Leak() hidden.Value { return hidden.Value{} }
|
||||
type Public struct { Result result }
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "internal generic argument is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type Box[T any] struct{}
|
||||
func New() Box[hidden.Value] { return Box[hidden.Value]{} }
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "internal alias argument is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type Box[T any] = struct{}
|
||||
func New() Box[hidden.Value] { return Box[hidden.Value]{} }
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "public generic argument and recursive methods are allowed",
|
||||
source: `package facade
|
||||
type Value struct{}
|
||||
type Box[T any] struct{}
|
||||
func New() Box[Value] { return Box[Value]{} }
|
||||
type result struct{}
|
||||
func NewResult() result { return result{} }
|
||||
func (*result) Next() *result { return nil }
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "direct internal signature is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
func Direct(value hidden.Value) {}
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "nested internal signature is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
func Nested(values []map[string]hidden.Value) {}
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "internal public field is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type Public struct { Value hidden.Value }
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "internal alias is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type Public = hidden.Value
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
{
|
||||
name: "promoted internal method is rejected",
|
||||
source: `package facade
|
||||
import "example.test/facade/internal/hidden"
|
||||
type wrapper struct { hidden.Value }
|
||||
type Public struct { wrapper }
|
||||
`,
|
||||
wantReject: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
pkg := fixturePackage(t, test.source)
|
||||
violations := publicSurfaceViolations(pkg, "example.test/facade")
|
||||
if got := len(violations) > 0; got != test.wantReject {
|
||||
t.Fatalf("rejected = %t, want %t; violations = %v", got, test.wantReject, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type packageMetadata struct {
|
||||
ImportPath string
|
||||
Export string
|
||||
Imports []string
|
||||
}
|
||||
|
||||
func listedPackages(t *testing.T, args ...string) []packageMetadata {
|
||||
t.Helper()
|
||||
command := exec.Command("go", append([]string{"list", "-json"}, args...)...)
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list %s: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(strings.NewReader(string(output)))
|
||||
var packages []packageMetadata
|
||||
for decoder.More() {
|
||||
var metadata packageMetadata
|
||||
if err := decoder.Decode(&metadata); err != nil {
|
||||
t.Fatalf("decode go list %s: %v", strings.Join(args, " "), err)
|
||||
}
|
||||
packages = append(packages, metadata)
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
func importCompiledPackage(t *testing.T, root packageMetadata) *types.Package {
|
||||
t.Helper()
|
||||
exports := map[string]string{root.ImportPath: root.Export}
|
||||
lookup := func(importPath string) (io.ReadCloser, error) {
|
||||
export, ok := exports[importPath]
|
||||
if !ok {
|
||||
metadata := listedPackages(t, "-export", importPath)
|
||||
if len(metadata) != 1 || metadata[0].Export == "" {
|
||||
return nil, fmt.Errorf("export data unavailable for %s", importPath)
|
||||
}
|
||||
export = metadata[0].Export
|
||||
exports[importPath] = export
|
||||
}
|
||||
return os.Open(export)
|
||||
}
|
||||
|
||||
compiled := importer.ForCompiler(token.NewFileSet(), runtime.Compiler, lookup)
|
||||
pkg, err := compiled.Import(root.ImportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("import compiled root package: %v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func publicSurfaceViolations(pkg *types.Package, modulePath string) []string {
|
||||
var violations []string
|
||||
seen := map[types.Type]bool{}
|
||||
record := func(where string, typ types.Type) {
|
||||
violations = append(violations, fmt.Sprintf("%s: %s", where, typ.String()))
|
||||
}
|
||||
var inspect func(types.Type, string)
|
||||
var inspectTuple func(*types.Tuple, string)
|
||||
var inspectTypeParams func(*types.TypeParamList, string)
|
||||
var inspectTypeArgs func(*types.TypeList, string)
|
||||
var inspectMethods func(*types.Named, string)
|
||||
inspect = func(typ types.Type, where string) {
|
||||
if typ == nil || seen[typ] {
|
||||
return
|
||||
}
|
||||
seen[typ] = true
|
||||
|
||||
switch value := typ.(type) {
|
||||
case *types.Named:
|
||||
inspectTypeArgs(value.TypeArgs(), where)
|
||||
if isInternalPackage(value.Obj().Pkg(), modulePath) {
|
||||
record(where, value)
|
||||
return
|
||||
}
|
||||
if value.Obj().Pkg() == pkg {
|
||||
inspectTypeParams(value.TypeParams(), where)
|
||||
inspect(value.Underlying(), where)
|
||||
inspectMethods(value, where)
|
||||
}
|
||||
case *types.Alias:
|
||||
inspectTypeArgs(value.TypeArgs(), where)
|
||||
if isInternalPackage(value.Obj().Pkg(), modulePath) {
|
||||
record(where, value)
|
||||
return
|
||||
}
|
||||
inspectTypeParams(value.TypeParams(), where)
|
||||
inspect(types.Unalias(value), where)
|
||||
case *types.Pointer:
|
||||
inspect(value.Elem(), where)
|
||||
case *types.Array:
|
||||
inspect(value.Elem(), where)
|
||||
case *types.Slice:
|
||||
inspect(value.Elem(), where)
|
||||
case *types.Map:
|
||||
inspect(value.Key(), where)
|
||||
inspect(value.Elem(), where)
|
||||
case *types.Chan:
|
||||
inspect(value.Elem(), where)
|
||||
case *types.Struct:
|
||||
for index := 0; index < value.NumFields(); index++ {
|
||||
field := value.Field(index)
|
||||
if field.Exported() {
|
||||
inspect(field.Type(), where+"."+field.Name())
|
||||
}
|
||||
}
|
||||
case *types.Interface:
|
||||
value.Complete()
|
||||
for index := 0; index < value.NumEmbeddeds(); index++ {
|
||||
inspect(value.EmbeddedType(index), where)
|
||||
}
|
||||
for index := 0; index < value.NumMethods(); index++ {
|
||||
method := value.Method(index)
|
||||
if method.Exported() {
|
||||
inspect(method.Type(), where+"."+method.Name())
|
||||
}
|
||||
}
|
||||
case *types.Signature:
|
||||
if value.Recv() != nil {
|
||||
inspect(value.Recv().Type(), where)
|
||||
}
|
||||
inspectTuple(value.Params(), where)
|
||||
inspectTuple(value.Results(), where)
|
||||
inspectTypeParams(value.TypeParams(), where)
|
||||
case *types.TypeParam:
|
||||
inspect(value.Constraint(), where)
|
||||
case *types.Union:
|
||||
for index := 0; index < value.Len(); index++ {
|
||||
inspect(value.Term(index).Type(), where)
|
||||
}
|
||||
}
|
||||
}
|
||||
inspectTuple = func(tuple *types.Tuple, where string) {
|
||||
for index := 0; index < tuple.Len(); index++ {
|
||||
inspect(tuple.At(index).Type(), where)
|
||||
}
|
||||
}
|
||||
inspectTypeParams = func(params *types.TypeParamList, where string) {
|
||||
if params == nil {
|
||||
return
|
||||
}
|
||||
for index := 0; index < params.Len(); index++ {
|
||||
inspect(params.At(index), where)
|
||||
}
|
||||
}
|
||||
inspectTypeArgs = func(args *types.TypeList, where string) {
|
||||
for index := 0; index < args.Len(); index++ {
|
||||
inspect(args.At(index), where)
|
||||
}
|
||||
}
|
||||
inspectMethods = func(named *types.Named, where string) {
|
||||
for _, methodSet := range []*types.MethodSet{types.NewMethodSet(named), types.NewMethodSet(types.NewPointer(named))} {
|
||||
for index := 0; index < methodSet.Len(); index++ {
|
||||
selection := methodSet.At(index)
|
||||
method := selection.Obj().(*types.Func)
|
||||
if !method.Exported() {
|
||||
continue
|
||||
}
|
||||
if isInternalPackage(method.Pkg(), modulePath) {
|
||||
record(where+"."+method.Name(), selection.Type())
|
||||
continue
|
||||
}
|
||||
inspect(selection.Type(), where+"."+method.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range pkg.Scope().Names() {
|
||||
if token.IsExported(name) {
|
||||
inspect(pkg.Scope().Lookup(name).Type(), name)
|
||||
}
|
||||
}
|
||||
sort.Strings(violations)
|
||||
return compactStrings(violations)
|
||||
}
|
||||
|
||||
func isInternalPackage(pkg *types.Package, modulePath string) bool {
|
||||
return pkg != nil && strings.HasPrefix(pkg.Path(), modulePath+"/internal/")
|
||||
}
|
||||
|
||||
func compactStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
unique := values[:1]
|
||||
for _, value := range values[1:] {
|
||||
if value != unique[len(unique)-1] {
|
||||
unique = append(unique, value)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func fixturePackage(t *testing.T, source string) *types.Package {
|
||||
t.Helper()
|
||||
const rootPath = "example.test/facade"
|
||||
const internalPath = rootPath + "/internal/hidden"
|
||||
fset := token.NewFileSet()
|
||||
internalFile, err := parser.ParseFile(fset, "hidden.go", `package hidden
|
||||
type Value struct{}
|
||||
func (Value) Exposed() {}
|
||||
`, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse internal fixture: %v", err)
|
||||
}
|
||||
internalPkg, err := (&types.Config{}).Check(internalPath, fset, []*ast.File{internalFile}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("type-check internal fixture: %v", err)
|
||||
}
|
||||
rootFile, err := parser.ParseFile(fset, "facade.go", source, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse facade fixture: %v", err)
|
||||
}
|
||||
pkg, err := (&types.Config{Importer: fixtureImporter(func(path string) (*types.Package, error) {
|
||||
if path == internalPath {
|
||||
return internalPkg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected fixture import %s", path)
|
||||
})}).Check(rootPath, fset, []*ast.File{rootFile}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("type-check facade fixture: %v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
type fixtureImporter func(string) (*types.Package, error)
|
||||
|
||||
func (f fixtureImporter) Import(path string) (*types.Package, error) { return f(path) }
|
||||
@@ -1203,6 +1203,105 @@ func TestFallbackProfileSourcePrecedence(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPromptkitOwnedProfilesUseExistingProfileWorkflows(t *testing.T) {
|
||||
const (
|
||||
standardID = "promptkit-openrouter-standard"
|
||||
autoID = "promptkit-openrouter-auto"
|
||||
)
|
||||
|
||||
t.Run("inspection and preparation resolve both names", func(t *testing.T) {
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("prompt", standardID, "message"), "."),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
|
||||
for _, profileID := range []string{standardID, autoID} {
|
||||
inspection, err := engine.InspectProfile(context.Background(), profileID)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect %q: %v", profileID, err)
|
||||
}
|
||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: "prompt",
|
||||
ProfileID: profileID,
|
||||
APIKey: "test-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare %q: %v", profileID, err)
|
||||
}
|
||||
if inspection.ProfileID != profileID ||
|
||||
inspection.EffectiveModelParams.BackendID != promptkit.BackendOpenRouter ||
|
||||
prepared.SelectedProfileID != profileID ||
|
||||
prepared.SelectedBackendID != promptkit.BackendOpenRouter ||
|
||||
!reflect.DeepEqual(prepared.EffectiveModelParams, inspection.EffectiveModelParams) {
|
||||
t.Fatalf("inspection and preparation differ for %q: inspection=%#v prepared=%#v", profileID, inspection, prepared)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("consumer definitions override an owned profile and its base", func(t *testing.T) {
|
||||
auto, err := promptkit.NewEngine(promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("auto", autoID, "message"), "."),
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: autoID,
|
||||
Endpoint: "http://consumer.example/v1",
|
||||
Model: "consumer-auto-model",
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct override engine: %v", err)
|
||||
}
|
||||
autoPrepared, err := auto.Prepare(context.Background(), promptkit.RunRequest{PromptID: "auto"})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare overridden profile: %v", err)
|
||||
}
|
||||
if autoPrepared.EffectiveModelParams.Model != "consumer-auto-model" {
|
||||
t.Fatalf("owned profile override model = %q", autoPrepared.EffectiveModelParams.Model)
|
||||
}
|
||||
|
||||
standard, err := promptkit.NewEngine(promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("standard", standardID, "message"), "."),
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "gemini-flash-latest",
|
||||
Endpoint: "http://consumer.example/v1",
|
||||
Model: "consumer-base-model",
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct inherited override engine: %v", err)
|
||||
}
|
||||
standardPrepared, err := standard.Prepare(context.Background(), promptkit.RunRequest{PromptID: "standard"})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare profile with overridden base: %v", err)
|
||||
}
|
||||
if standardPrepared.EffectiveModelParams.Model != "consumer-base-model" {
|
||||
t.Fatalf("owned profile base override model = %q", standardPrepared.EffectiveModelParams.Model)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prompt default selection remains explicit", func(t *testing.T) {
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||
promptkit.WithPromptFS(contractPromptFS("default", "application-default", "message"), "."),
|
||||
promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "application-default",
|
||||
Endpoint: "http://consumer.example/v1",
|
||||
Model: "consumer-default-model",
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("construct default engine: %v", err)
|
||||
}
|
||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "default"})
|
||||
if err != nil {
|
||||
t.Fatalf("prepare default profile: %v", err)
|
||||
}
|
||||
if prepared.SelectedProfileID != "application-default" {
|
||||
t.Fatalf("selected default profile = %q", prepared.SelectedProfileID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFallbackProfileSourcePreservesLazyLoadingAndErrors(t *testing.T) {
|
||||
const profileID = "application-profile"
|
||||
|
||||
|
||||
424
testdata/builtin-catalog-v1.json
vendored
424
testdata/builtin-catalog-v1.json
vendored
@@ -1,424 +0,0 @@
|
||||
{
|
||||
"backends": [
|
||||
{
|
||||
"id": "openrouter",
|
||||
"endpoint": "https://openrouter.ai/api/v1",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"extra_params": null,
|
||||
"concurrency_limit": 16,
|
||||
"queue_capacity": 1024,
|
||||
"queue_capacity_set": true
|
||||
},
|
||||
{
|
||||
"id": "rakestrawhome",
|
||||
"endpoint": "https://inference.ai.rakestrawhome.com/v1",
|
||||
"api_key_env": "RAKESTRAWHOME_INFERENCE_API_KEY",
|
||||
"extra_params": null,
|
||||
"concurrency_limit": 4,
|
||||
"queue_capacity": 1024,
|
||||
"queue_capacity_set": true
|
||||
}
|
||||
],
|
||||
"profiles": [
|
||||
{
|
||||
"id": "aion-2",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "aion-labs/aion-2.0",
|
||||
"temperature": 0.72,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0.95,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "claude-fable-latest",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "~anthropic/claude-fable-latest",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 600,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-latest",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "~anthropic/claude-haiku-latest",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "medium",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-latest",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "~anthropic/claude-opus-latest",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-latest",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "~anthropic/claude-sonnet-latest",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "deepseek-3-2",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "deepseek/deepseek-v3.2",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "deepseek-4-flash",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "deepseek/deepseek-v4-flash",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "deepseek-4-pro",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gemini-2-flash",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "google/gemini-2.5-flash",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gemini-2-flash-lite",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "google/gemini-2.5-flash-lite",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gemini-2-pro",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "google/gemini-2.5-pro",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gemini-3-flash-lite",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "google/gemini-3.1-flash-lite",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gemini-flash-latest",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "~google/gemini-flash-latest",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gemini-pro-latest",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "~google/gemini-pro-latest",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gemma-4-31b",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "google/gemma-4-31b-it:exacto",
|
||||
"temperature": 0.15,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0.98,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gpt-5-mini",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "openai/gpt-5.4-mini",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "gpt-5-nano",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "openai/gpt-5.4-nano",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 240,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "minimax-m2",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "minimax/minimax-m2.5",
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0.95,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "minimax-m3",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "minimax/minimax-m3",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "mistral-large-2512",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "mistralai/mistral-large-2512",
|
||||
"temperature": 0.15,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0.98,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "",
|
||||
"reasoning_effort": "",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "mistral-medium-3-5",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "mistralai/mistral-medium-3-5",
|
||||
"temperature": 0.15,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0.98,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "mistral-small-3",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "mistralai/mistral-small-3.2-24b-instruct",
|
||||
"temperature": 0.05,
|
||||
"max_tokens": 0,
|
||||
"top_p": 1,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "",
|
||||
"reasoning_effort": "",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "mistral-small-4",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "mistralai/mistral-small-2603",
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0.98,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "nemotron-3-ultra",
|
||||
"base_profile": "",
|
||||
"backend": "openrouter",
|
||||
"endpoint": "",
|
||||
"model": "nvidia/nemotron-3-ultra-550b-a55b",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 180,
|
||||
"service_tier": "flex",
|
||||
"reasoning_effort": "high",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
},
|
||||
{
|
||||
"id": "rakestrawhome-gemma-4-31b",
|
||||
"base_profile": "",
|
||||
"backend": "rakestrawhome",
|
||||
"endpoint": "",
|
||||
"model": "google/gemma-4-31b-it",
|
||||
"temperature": 0,
|
||||
"max_tokens": 0,
|
||||
"top_p": 0,
|
||||
"timeout_seconds": 0,
|
||||
"service_tier": "",
|
||||
"reasoning_effort": "",
|
||||
"api_key_env": "",
|
||||
"api_key_required": false,
|
||||
"extra_params": null
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user