18 Commits

Author SHA1 Message Date
d79f1e9562 Prepare the v0.10.0 release 2026-09-22 14:06:35 +00:00
c8e4abcdbd Complete profile policy integration review 2026-09-22 12:33:29 +00:00
6ce54c1d79 Document catalog profile compatibility 2026-09-22 12:31:50 +00:00
2356f7c73d Adopt published version two catalogs 2026-09-22 12:26:22 +00:00
ba1e85a805 Add Promptkit-owned profile definitions 2026-09-22 12:23:07 +00:00
4c3828857c Reserve Promptkit catalog profile names 2026-09-22 12:17:41 +00:00
02fd09955d Record published catalog versions 2026-09-22 12:15:41 +00:00
7389410966 Record catalog contract preparation 2026-09-22 12:11:24 +00:00
3bc624ec3f Plan the profile policy implementation 2026-09-22 12:06:24 +00:00
e5870453f9 Close gaps in public boundary checks 2026-09-22 11:40:25 +00:00
06251e385e Record the completed public facade refactor 2026-09-22 04:50:40 +00:00
722b5ddf59 Add public facade boundary checks 2026-09-22 04:48:58 +00:00
47bc9a6655 Move option source construction into the internal engine 2026-09-22 04:44:41 +00:00
bd2b7b1862 Move engine composition into the internal engine package 2026-09-22 04:34:36 +00:00
c892eacb65 Move the memory profile repository into the profile package 2026-09-22 04:30:02 +00:00
ff73104f7e Record the public facade compatibility baseline 2026-09-22 04:26:36 +00:00
7a858c2365 Plan the public facade refactor 2026-09-22 04:21:13 +00:00
e4540aa619 Plan the public facade refactor and profile policy updates 2026-09-22 04:13:52 +00:00
30 changed files with 1417 additions and 847 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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,

View File

@@ -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

View File

@@ -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. |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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
View 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
View File

@@ -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)
}

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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)
}

View File

@@ -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
View 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)
}

View 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
}

View 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")
}
})
}

View 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
}

View 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")
}
})
}
}

View File

@@ -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 {

View File

@@ -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
View 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) }

View File

@@ -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"

View File

@@ -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
}
]
}