Implement support for OpenRouter sticky routing via a session_id variable
This commit is contained in:
@@ -83,6 +83,7 @@ Notes:
|
|||||||
|
|
||||||
- `--input name=path` maps prompt input names to local file paths.
|
- `--input name=path` maps prompt input names to local file paths.
|
||||||
- `--var name=value` maps template variable names to values.
|
- `--var name=value` maps template variable names to values.
|
||||||
|
- If a prompt defines `session_id: "{{ .session_id }}"`, pass the OpenRouter sticky-routing value with `--var session_id=<value>`.
|
||||||
- Both flags can be repeated.
|
- Both flags can be repeated.
|
||||||
- Both flags also support comma-separated batches, for example:
|
- Both flags also support comma-separated batches, for example:
|
||||||
- `--input transcript=./t.md,glossary=./g.yml`
|
- `--input transcript=./t.md,glossary=./g.yml`
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ Field reference:
|
|||||||
- `version` (required): prompt version.
|
- `version` (required): prompt version.
|
||||||
- `default_profile` (optional): profile ID used when request does not provide `profile_id`.
|
- `default_profile` (optional): profile ID used when request does not provide `profile_id`.
|
||||||
- `description` (optional): prompt description.
|
- `description` (optional): prompt description.
|
||||||
|
- `session_id` (optional): Go-template string for OpenRouter sticky-routing `session_id`; rendered from request vars.
|
||||||
- `inputs` (optional list): expected named inputs.
|
- `inputs` (optional list): expected named inputs.
|
||||||
- `messages` (required list): prompt message templates.
|
- `messages` (required list): prompt message templates.
|
||||||
- `output` (required object): output contract.
|
- `output` (required object): output contract.
|
||||||
@@ -150,6 +151,14 @@ messages:
|
|||||||
|
|
||||||
Use cache control on stable reusable prompt content. Dynamic per-run inputs before the cache-controlled message change the provider cache key.
|
Use cache control on stable reusable prompt content. Dynamic per-run inputs before the cache-controlled message change the provider cache key.
|
||||||
|
|
||||||
|
Example prompt-level session ID:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
session_id: "{{ .session_id }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
When configured, `session_id` is rendered with the same variable context as messages. The rendered value is trimmed, omitted when empty, and rejected if longer than 256 characters. CLI callers pass the value through `--var session_id=<value>`; HTTP callers pass it through `"vars": {"session_id": "<value>"}`.
|
||||||
|
|
||||||
`output` fields:
|
`output` fields:
|
||||||
|
|
||||||
- `format` (required): `text`, `markdown`, or `json`.
|
- `format` (required): `text`, `markdown`, or `json`.
|
||||||
@@ -205,7 +214,7 @@ Profile rules:
|
|||||||
|
|
||||||
Current outbound request behavior:
|
Current outbound request behavior:
|
||||||
|
|
||||||
- The OpenAI-compatible client currently serializes: `model`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, and optional `response_format` for `json_schema` prompts.
|
- The OpenAI-compatible client currently serializes: `model`, optional `session_id`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, and optional `response_format` for `json_schema` prompts.
|
||||||
- Messages without `cache_control` serialize with string `content`.
|
- Messages without `cache_control` serialize with string `content`.
|
||||||
- Messages with `cache_control` serialize as a single text content-block array containing `cache_control`.
|
- Messages with `cache_control` serialize as a single text content-block array containing `cache_control`.
|
||||||
- `reasoning_effort` and `extra_params` are parsed and carried in effective settings, but are not currently serialized into outbound chat-completions requests.
|
- `reasoning_effort` and `extra_params` are parsed and carried in effective settings, but are not currently serialized into outbound chat-completions requests.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Example:
|
|||||||
Serialized JSON fields:
|
Serialized JSON fields:
|
||||||
|
|
||||||
- `model` (required after fallback resolution)
|
- `model` (required after fallback resolution)
|
||||||
|
- `session_id` (only when the rendered prompt includes a non-empty session ID)
|
||||||
- `messages` (rendered prompt messages)
|
- `messages` (rendered prompt messages)
|
||||||
- `temperature` (only when non-zero)
|
- `temperature` (only when non-zero)
|
||||||
- `max_tokens` (only when non-zero)
|
- `max_tokens` (only when non-zero)
|
||||||
@@ -35,6 +36,8 @@ Serialized JSON fields:
|
|||||||
|
|
||||||
`service_tier` is provider-specific. OpenRouter currently documents request values such as `flex` and `priority`; Scriptorium forwards any non-empty configured value and lets the backend validate support.
|
`service_tier` is provider-specific. OpenRouter currently documents request values such as `flex` and `priority`; Scriptorium forwards any non-empty configured value and lets the backend validate support.
|
||||||
|
|
||||||
|
`session_id` is rendered from prompt YAML using request variables and serialized as a top-level JSON request field. Scriptorium does not send an `x-session-id` header. Empty rendered session IDs are omitted, and values longer than 256 characters are rejected before the HTTP request.
|
||||||
|
|
||||||
Messages without prompt cache control serialize with string `content`:
|
Messages without prompt cache control serialize with string `content`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -140,7 +143,7 @@ The following fields may exist in profile/effective settings but are not current
|
|||||||
- `reasoning_effort`
|
- `reasoning_effort`
|
||||||
- `extra_params`
|
- `extra_params`
|
||||||
|
|
||||||
The client also does not serialize top-level `cache_control` or `session_id`.
|
The client also does not serialize top-level `cache_control`.
|
||||||
|
|
||||||
No built-in retries, tool-calls, or multi-request payload modes are implemented in this client.
|
No built-in retries, tool-calls, or multi-request payload modes are implemented in this client.
|
||||||
|
|
||||||
|
|||||||
@@ -146,5 +146,5 @@ Behavior highlights:
|
|||||||
- Adapter packages do not own runner decision logic.
|
- Adapter packages do not own runner decision logic.
|
||||||
- External request/response strictness is part of contract stability.
|
- External request/response strictness is part of contract stability.
|
||||||
- Prepared-render output never includes resolved API key values.
|
- Prepared-render output never includes resolved API key values.
|
||||||
- Outbound OpenAI-compatible request includes only currently serialized fields (`model`, `messages`, optional `temperature`, `max_tokens`, `top_p`, optional `service_tier`, optional `response_format`).
|
- Outbound OpenAI-compatible request includes only currently serialized fields (`model`, optional `session_id`, `messages`, optional `temperature`, `max_tokens`, `top_p`, optional `service_tier`, optional `response_format`).
|
||||||
- Outbound cache control is message-level only; no top-level cache-control/session fields are serialized.
|
- Outbound cache control is message-level only; no top-level cache-control field is serialized.
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ These are intentionally out of scope for the initial feature:
|
|||||||
- Image, tool, or non-text content blocks.
|
- Image, tool, or non-text content blocks.
|
||||||
- Provider-specific automatic prompt caching toggles.
|
- Provider-specific automatic prompt caching toggles.
|
||||||
- Top-level OpenRouter `cache_control`.
|
- Top-level OpenRouter `cache_control`.
|
||||||
- Top-level OpenRouter `session_id`.
|
|
||||||
- General-purpose serialization of `extra_params`.
|
- General-purpose serialization of `extra_params`.
|
||||||
- Provider-specific validation profiles for cache-control limits.
|
- Provider-specific validation profiles for cache-control limits.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
This plan implements the feature defined in `docs/roadmap/cache.md`.
|
This plan implements the feature defined in `docs/roadmap/cache.md`.
|
||||||
|
|
||||||
The implementation target is message-level Anthropic/OpenRouter-style cache control for rendered prompt messages, plus cache usage observability. Do not implement deferred roadmap items such as `content_blocks`, top-level `cache_control`, `session_id`, or general `extra_params` serialization.
|
The implementation target is message-level Anthropic/OpenRouter-style cache control for rendered prompt messages, plus cache usage observability. Do not implement deferred roadmap items such as `content_blocks`, top-level `cache_control`, or general `extra_params` serialization.
|
||||||
|
|
||||||
Follow the policy documents under `docs/policy/` while implementing:
|
Follow the policy documents under `docs/policy/` while implementing:
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ Request serialization decisions:
|
|||||||
- Keep string content for messages without cache control.
|
- Keep string content for messages without cache control.
|
||||||
- Use content blocks only when `RenderedMessage.CacheControl` is non-nil.
|
- Use content blocks only when `RenderedMessage.CacheControl` is non-nil.
|
||||||
- Do not introduce `content_blocks` prompt syntax.
|
- Do not introduce `content_blocks` prompt syntax.
|
||||||
- Do not serialize top-level `cache_control`, top-level `session_id`, or `extra_params`.
|
- Do not serialize top-level `cache_control` or `extra_params`.
|
||||||
- Split request and response wire message structs. The current `openAIChatMessage` is used in both request and response paths; changing request content to `any` should not make response decoding less strict than necessary.
|
- Split request and response wire message structs. The current `openAIChatMessage` is used in both request and response paths; changing request content to `any` should not make response decoding less strict than necessary.
|
||||||
|
|
||||||
Use request wire types equivalent to:
|
Use request wire types equivalent to:
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ const (
|
|||||||
CacheControlEphemeral CacheControlType = "ephemeral"
|
CacheControlEphemeral CacheControlType = "ephemeral"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SessionIDMaxLength is OpenRouter's documented maximum session_id length.
|
||||||
|
SessionIDMaxLength = 256
|
||||||
|
)
|
||||||
|
|
||||||
// CacheControl describes provider cache metadata attached to prompt content.
|
// CacheControl describes provider cache metadata attached to prompt content.
|
||||||
type CacheControl struct {
|
type CacheControl struct {
|
||||||
Type CacheControlType `yaml:"type" json:"type"`
|
Type CacheControlType `yaml:"type" json:"type"`
|
||||||
@@ -98,6 +103,7 @@ type PreparedRun struct {
|
|||||||
OutputContract OutputContract `json:"output_contract"`
|
OutputContract OutputContract `json:"output_contract"`
|
||||||
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
||||||
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
||||||
Messages []RenderedMessage `json:"messages"`
|
Messages []RenderedMessage `json:"messages"`
|
||||||
StartTime time.Time `json:"start_time,omitempty"`
|
StartTime time.Time `json:"start_time,omitempty"`
|
||||||
@@ -128,6 +134,7 @@ type PromptDefinition struct {
|
|||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
DefaultProfile string `yaml:"default_profile"`
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
|
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
||||||
Inputs []PromptInput `yaml:"inputs"`
|
Inputs []PromptInput `yaml:"inputs"`
|
||||||
Templates []PromptMessageTemplate `yaml:"templates"`
|
Templates []PromptMessageTemplate `yaml:"templates"`
|
||||||
OutputFormat OutputFormat `yaml:"output_format"`
|
OutputFormat OutputFormat `yaml:"output_format"`
|
||||||
@@ -189,7 +196,8 @@ type OutputContract struct {
|
|||||||
|
|
||||||
// RenderedPrompt represents the prompt after template application.
|
// RenderedPrompt represents the prompt after template application.
|
||||||
type RenderedPrompt struct {
|
type RenderedPrompt struct {
|
||||||
Messages []RenderedMessage `json:"messages"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Messages []RenderedMessage `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderedMessage is a single message in a rendered prompt.
|
// RenderedMessage is a single message in a rendered prompt.
|
||||||
|
|||||||
@@ -102,3 +102,39 @@ func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T)
|
|||||||
t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1])
|
t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) {
|
||||||
|
prepared := PreparedRun{
|
||||||
|
PromptID: "prompt.id",
|
||||||
|
SelectedProfileID: "local-fast",
|
||||||
|
EffectiveModelParams: ExecutionTarget{
|
||||||
|
Endpoint: "http://llm/v1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
},
|
||||||
|
SessionID: "session-123",
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if decoded["session_id"] != "session-123" {
|
||||||
|
t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared.SessionID = ""
|
||||||
|
b, err = json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "session_id") {
|
||||||
|
t.Fatalf("expected empty session_id to be omitted, got %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er
|
|||||||
if prepared.PromptHash != "" {
|
if prepared.PromptHash != "" {
|
||||||
fmt.Fprintf(&b, "prompt_hash: %s\n", prepared.PromptHash)
|
fmt.Fprintf(&b, "prompt_hash: %s\n", prepared.PromptHash)
|
||||||
}
|
}
|
||||||
|
if prepared.SessionID != "" {
|
||||||
|
fmt.Fprintf(&b, "session_id: %s\n", prepared.SessionID)
|
||||||
|
}
|
||||||
fmt.Fprintf(&b, "rendered_prompt_hash: %s\n", prepared.RenderedPromptHash)
|
fmt.Fprintf(&b, "rendered_prompt_hash: %s\n", prepared.RenderedPromptHash)
|
||||||
|
|
||||||
target := prepared.EffectiveModelParams
|
target := prepared.EffectiveModelParams
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ func TestTextFormatterIncludesMessageCacheControlBeforeContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTextFormatterIncludesSessionIDWhenPresent(t *testing.T) {
|
||||||
|
prepared := samplePreparedRun()
|
||||||
|
prepared.SessionID = "session-123"
|
||||||
|
|
||||||
|
out, err := FormatPreparedRun(prepared, PreparedRunFormatText)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "session_id: session-123\n") {
|
||||||
|
t.Fatalf("expected session_id in text output, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) {
|
func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) {
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
prepared.Messages = []domain.RenderedMessage{
|
prepared.Messages = []domain.RenderedMessage{
|
||||||
@@ -116,6 +129,7 @@ func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) {
|
|||||||
|
|
||||||
func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
|
func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
|
||||||
prepared := samplePreparedRun()
|
prepared := samplePreparedRun()
|
||||||
|
prepared.SessionID = "session-123"
|
||||||
|
|
||||||
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
|
out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,6 +153,9 @@ func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) {
|
|||||||
if decoded["rendered_prompt_hash"] != "rendered-hash" {
|
if decoded["rendered_prompt_hash"] != "rendered-hash" {
|
||||||
t.Fatalf("expected rendered_prompt_hash in json output, got %#v", decoded["rendered_prompt_hash"])
|
t.Fatalf("expected rendered_prompt_hash in json output, got %#v", decoded["rendered_prompt_hash"])
|
||||||
}
|
}
|
||||||
|
if decoded["session_id"] != "session-123" {
|
||||||
|
t.Fatalf("expected session_id in json output, got %#v", decoded["session_id"])
|
||||||
|
}
|
||||||
if _, ok := decoded["effective_model_params"]; !ok {
|
if _, ok := decoded["effective_model_params"]; !ok {
|
||||||
t.Fatalf("expected effective_model_params in json output, got %#v", decoded)
|
t.Fatalf("expected effective_model_params in json output, got %#v", decoded)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
@@ -169,6 +170,12 @@ func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultMod
|
|||||||
wireReq := openAIChatRequest{
|
wireReq := openAIChatRequest{
|
||||||
Model: model,
|
Model: model,
|
||||||
}
|
}
|
||||||
|
if sessionID := strings.TrimSpace(req.Prompt.SessionID); sessionID != "" {
|
||||||
|
if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength {
|
||||||
|
return openAIChatRequest{}, fmt.Errorf("session_id length %d exceeds maximum %d", n, domain.SessionIDMaxLength)
|
||||||
|
}
|
||||||
|
wireReq.SessionID = sessionID
|
||||||
|
}
|
||||||
|
|
||||||
wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages))
|
wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages))
|
||||||
for _, msg := range req.Prompt.Messages {
|
for _, msg := range req.Prompt.Messages {
|
||||||
@@ -200,6 +207,7 @@ func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultMod
|
|||||||
|
|
||||||
type openAIChatRequest struct {
|
type openAIChatRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Messages []openAIChatRequestMessage `json:"messages"`
|
Messages []openAIChatRequestMessage `json:"messages"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func TestOpenAICompatibleClientSerializesCacheControlledMessageAsContentBlock(t
|
|||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, forbidden := range []string{"cache_control", "session_id", "extra_params"} {
|
for _, forbidden := range []string{"cache_control", "extra_params"} {
|
||||||
if _, exists := observedBody[forbidden]; exists {
|
if _, exists := observedBody[forbidden]; exists {
|
||||||
t.Fatalf("expected top-level %s to be omitted, got %#v", forbidden, observedBody[forbidden])
|
t.Fatalf("expected top-level %s to be omitted, got %#v", forbidden, observedBody[forbidden])
|
||||||
}
|
}
|
||||||
@@ -262,6 +262,96 @@ func TestOpenAICompatibleClientOmitsEmptyCacheControlTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientSerializesSessionID(t *testing.T) {
|
||||||
|
var observedBody map[string]any
|
||||||
|
var observedSessionHeader string
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
observedSessionHeader = r.Header.Get("x-session-id")
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{
|
||||||
|
SessionID: " session-123 ",
|
||||||
|
Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}},
|
||||||
|
},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if observedBody["session_id"] != "session-123" {
|
||||||
|
t.Fatalf("expected top-level session_id, got %#v", observedBody["session_id"])
|
||||||
|
}
|
||||||
|
if observedSessionHeader != "" {
|
||||||
|
t.Fatalf("did not expect x-session-id header, got %q", observedSessionHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientOmitsEmptySessionID(t *testing.T) {
|
||||||
|
var observedBody map[string]any
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{
|
||||||
|
SessionID: " ",
|
||||||
|
Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}},
|
||||||
|
},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, exists := observedBody["session_id"]; exists {
|
||||||
|
t.Fatalf("expected empty session_id to be omitted, got %#v", observedBody["session_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientRejectsTooLongSessionID(t *testing.T) {
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "http://example.com/v1",
|
||||||
|
Model: "model",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{
|
||||||
|
SessionID: strings.Repeat("x", domain.SessionIDMaxLength+1),
|
||||||
|
Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid request error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrInvalidRequest) {
|
||||||
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAICompatibleClientParsesCacheUsage(t *testing.T) {
|
func TestOpenAICompatibleClientParsesCacheUsage(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{
|
_, _ = w.Write([]byte(`{
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -50,6 +52,11 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionID, err := renderSessionID(definition.SessionID, funcs, vars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var renderedMessages []domain.RenderedMessage
|
var renderedMessages []domain.RenderedMessage
|
||||||
|
|
||||||
for i, tmplMsg := range definition.Templates {
|
for i, tmplMsg := range definition.Templates {
|
||||||
@@ -82,10 +89,33 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &domain.RenderedPrompt{
|
return &domain.RenderedPrompt{
|
||||||
Messages: renderedMessages,
|
SessionID: sessionID,
|
||||||
|
Messages: renderedMessages,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderSessionID(raw string, funcs template.FuncMap, vars map[string]string) (string, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("session_id").Funcs(funcs).Option("missingkey=error").Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, vars); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %w", ErrRenderFailure, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := strings.TrimSpace(buf.String())
|
||||||
|
if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength {
|
||||||
|
return "", fmt.Errorf("%w: session_id length %d exceeds maximum %d", ErrRenderFailure, n, domain.SessionIDMaxLength)
|
||||||
|
}
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func cloneCacheControl(in *domain.CacheControl) *domain.CacheControl {
|
func cloneCacheControl(in *domain.CacheControl) *domain.CacheControl {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package prompt
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
@@ -155,6 +156,78 @@ func TestGoRenderer_Render(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("rendering session id from vars", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: " {{ .session_id }} ",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, map[string]string{
|
||||||
|
"tone": "concise",
|
||||||
|
"session_id": "agent-session-123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.SessionID != "agent-session-123" {
|
||||||
|
t.Fatalf("unexpected session id: %q", res.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty rendered session id is omitted", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: " ",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.SessionID != "" {
|
||||||
|
t.Fatalf("expected empty session id, got %q", res.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing session id var fails rendering", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: "{{ .session_id }}",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("too long rendered session id fails rendering", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: "{{ .session_id }}",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, map[string]string{
|
||||||
|
"tone": "concise",
|
||||||
|
"session_id": strings.Repeat("x", domain.SessionIDMaxLength+1),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("inserting required input artifact", func(t *testing.T) {
|
t.Run("inserting required input artifact", func(t *testing.T) {
|
||||||
def := &domain.PromptDefinition{
|
def := &domain.PromptDefinition{
|
||||||
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type promptDefinitionFile struct {
|
|||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
DefaultProfile *string `yaml:"default_profile"`
|
DefaultProfile *string `yaml:"default_profile"`
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
|
SessionID string `yaml:"session_id"`
|
||||||
Inputs []promptInputFile `yaml:"inputs"`
|
Inputs []promptInputFile `yaml:"inputs"`
|
||||||
Messages []promptMessageFile `yaml:"messages"`
|
Messages []promptMessageFile `yaml:"messages"`
|
||||||
Output promptOutputContractFile `yaml:"output"`
|
Output promptOutputContractFile `yaml:"output"`
|
||||||
@@ -274,6 +275,7 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d
|
|||||||
Version: version,
|
Version: version,
|
||||||
DefaultProfile: defaultProfile,
|
DefaultProfile: defaultProfile,
|
||||||
Description: strings.TrimSpace(raw.Description),
|
Description: strings.TrimSpace(raw.Description),
|
||||||
|
SessionID: strings.TrimSpace(raw.SessionID),
|
||||||
Inputs: inputs,
|
Inputs: inputs,
|
||||||
Templates: templates,
|
Templates: templates,
|
||||||
OutputFormat: raw.Output.Format,
|
OutputFormat: raw.Output.Format,
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ func TestFilesystemRepository_GetPromptDefinition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("valid session id template", func(t *testing.T) {
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "valid-session-id", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.SessionID != "{{ .session_id }}" {
|
||||||
|
t.Fatalf("expected trimmed session_id template, got %q", p.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("valid nested file-backed prompt resolves content file relative to nested YAML", func(t *testing.T) {
|
t.Run("valid nested file-backed prompt resolves content file relative to nested YAML", func(t *testing.T) {
|
||||||
nestedDir := filepath.Join(tmpDir, "dnd", "recap")
|
nestedDir := filepath.Join(tmpDir, "dnd", "recap")
|
||||||
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
|||||||
10
internal/promptdef/testdata/valid_session_id.yaml
vendored
Normal file
10
internal/promptdef/testdata/valid_session_id.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
id: valid-session-id
|
||||||
|
version: "1.0.0"
|
||||||
|
session_id: " {{ .session_id }} "
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Hello.
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
@@ -90,7 +90,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{
|
genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{
|
||||||
Prompt: domain.RenderedPrompt{Messages: prepared.Messages},
|
Prompt: domain.RenderedPrompt{SessionID: prepared.SessionID, Messages: prepared.Messages},
|
||||||
Target: prepared.EffectiveModelParams,
|
Target: prepared.EffectiveModelParams,
|
||||||
StructuredOutput: prepared.StructuredOutput,
|
StructuredOutput: prepared.StructuredOutput,
|
||||||
})
|
})
|
||||||
@@ -233,6 +233,7 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr
|
|||||||
OutputContract: effectiveContract,
|
OutputContract: effectiveContract,
|
||||||
StructuredOutput: structuredOutput,
|
StructuredOutput: structuredOutput,
|
||||||
InputHashes: inputHashes,
|
InputHashes: inputHashes,
|
||||||
|
SessionID: renderedPrompt.SessionID,
|
||||||
RenderedPromptHash: hashRenderedPrompt(*renderedPrompt),
|
RenderedPromptHash: hashRenderedPrompt(*renderedPrompt),
|
||||||
Messages: renderedPrompt.Messages,
|
Messages: renderedPrompt.Messages,
|
||||||
StartTime: start,
|
StartTime: start,
|
||||||
@@ -424,6 +425,11 @@ func resolveOutputContract(def *domain.PromptDefinition, override *domain.Output
|
|||||||
|
|
||||||
func hashRenderedPrompt(p domain.RenderedPrompt) string {
|
func hashRenderedPrompt(p domain.RenderedPrompt) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
if p.SessionID != "" {
|
||||||
|
b.WriteString("session_id=")
|
||||||
|
b.WriteString(p.SessionID)
|
||||||
|
b.WriteString("\n---\n")
|
||||||
|
}
|
||||||
for _, msg := range p.Messages {
|
for _, msg := range p.Messages {
|
||||||
b.WriteString(msg.Role)
|
b.WriteString(msg.Role)
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) {
|
|||||||
"a://t": {Body: []byte("transcript"), Hash: hashString("transcript")},
|
"a://t": {Body: []byte("transcript"), Hash: hashString("transcript")},
|
||||||
"a://g": {Body: []byte("glossary"), Hash: hashString("glossary")},
|
"a://g": {Body: []byte("glossary"), Hash: hashString("glossary")},
|
||||||
}}
|
}}
|
||||||
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session-123", Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
||||||
llmClient := &fakeLLM{forbid: true}
|
llmClient := &fakeLLM{forbid: true}
|
||||||
|
|
||||||
runner := NewRunner(promptRepo, execRepo, reader, renderer, llmClient, nil)
|
runner := NewRunner(promptRepo, execRepo, reader, renderer, llmClient, nil)
|
||||||
@@ -198,6 +198,9 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) {
|
|||||||
if len(prepared.Messages) != 2 {
|
if len(prepared.Messages) != 2 {
|
||||||
t.Fatalf("expected two messages, got %d", len(prepared.Messages))
|
t.Fatalf("expected two messages, got %d", len(prepared.Messages))
|
||||||
}
|
}
|
||||||
|
if prepared.SessionID != "session-123" {
|
||||||
|
t.Fatalf("expected prepared session id, got %q", prepared.SessionID)
|
||||||
|
}
|
||||||
if llmClient.calls != 0 {
|
if llmClient.calls != 0 {
|
||||||
t.Fatalf("prepare should not call llm, calls=%d", llmClient.calls)
|
t.Fatalf("prepare should not call llm, calls=%d", llmClient.calls)
|
||||||
}
|
}
|
||||||
@@ -632,6 +635,45 @@ func TestHashRenderedPromptIncludesCacheControlWhenPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHashRenderedPromptIncludesSessionIDWhenPresent(t *testing.T) {
|
||||||
|
withoutSession := domain.RenderedPrompt{Messages: []domain.RenderedMessage{
|
||||||
|
{Role: "system", Content: "sys"},
|
||||||
|
{Role: "user", Content: "usr"},
|
||||||
|
}}
|
||||||
|
withSession := domain.RenderedPrompt{
|
||||||
|
SessionID: "session-123",
|
||||||
|
Messages: []domain.RenderedMessage{
|
||||||
|
{Role: "system", Content: "sys"},
|
||||||
|
{Role: "user", Content: "usr"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alsoWithSession := domain.RenderedPrompt{
|
||||||
|
SessionID: "session-123",
|
||||||
|
Messages: []domain.RenderedMessage{
|
||||||
|
{Role: "system", Content: "sys"},
|
||||||
|
{Role: "user", Content: "usr"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
otherSession := domain.RenderedPrompt{
|
||||||
|
SessionID: "session-456",
|
||||||
|
Messages: []domain.RenderedMessage{
|
||||||
|
{Role: "system", Content: "sys"},
|
||||||
|
{Role: "user", Content: "usr"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionHash := hashRenderedPrompt(withSession)
|
||||||
|
if sessionHash == hashRenderedPrompt(withoutSession) {
|
||||||
|
t.Fatal("expected session_id to change rendered prompt hash")
|
||||||
|
}
|
||||||
|
if sessionHash != hashRenderedPrompt(alsoWithSession) {
|
||||||
|
t.Fatal("expected identical session_id to produce stable hash")
|
||||||
|
}
|
||||||
|
if sessionHash == hashRenderedPrompt(otherSession) {
|
||||||
|
t.Fatal("expected session_id value changes to affect rendered prompt hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunnerRunSuccessful(t *testing.T) {
|
func TestRunnerRunSuccessful(t *testing.T) {
|
||||||
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)}
|
promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)}
|
||||||
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}
|
execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}
|
||||||
@@ -639,7 +681,7 @@ func TestRunnerRunSuccessful(t *testing.T) {
|
|||||||
"a://t": {Body: []byte("transcript"), Hash: hashString("transcript")},
|
"a://t": {Body: []byte("transcript"), Hash: hashString("transcript")},
|
||||||
"a://g": {Body: []byte("glossary"), Hash: hashString("glossary")},
|
"a://g": {Body: []byte("glossary"), Hash: hashString("glossary")},
|
||||||
}}
|
}}
|
||||||
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session-123", Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}}
|
||||||
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "# recap", Usage: domain.TokenUsage{TotalTokens: 7}}}
|
llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "# recap", Usage: domain.TokenUsage{TotalTokens: 7}}}
|
||||||
|
|
||||||
runner := NewRunner(promptRepo, execRepo, reader, renderer, llmClient, nil)
|
runner := NewRunner(promptRepo, execRepo, reader, renderer, llmClient, nil)
|
||||||
@@ -686,6 +728,9 @@ func TestRunnerRunSuccessful(t *testing.T) {
|
|||||||
if llmClient.lastReq.Target.TimeoutSeconds != 90 {
|
if llmClient.lastReq.Target.TimeoutSeconds != 90 {
|
||||||
t.Fatalf("expected timeout propagation, got %d", llmClient.lastReq.Target.TimeoutSeconds)
|
t.Fatalf("expected timeout propagation, got %d", llmClient.lastReq.Target.TimeoutSeconds)
|
||||||
}
|
}
|
||||||
|
if llmClient.lastReq.Prompt.SessionID != "session-123" {
|
||||||
|
t.Fatalf("expected session id to be sent to llm, got %q", llmClient.lastReq.Prompt.SessionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T) {
|
func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user