Files
sanderling/internal/llmclient/client.go
pj 7343085614 llm action-selection backend (#68)
* feat(spec): add llm() action-backend marker

* feat(spec): make llm marker inert on the JS picker

* feat(spec): expose __sanderlingSampleInput__ corpus draw

* feat(openrouter): minimal chat-completions client

* test(openrouter): cover request shape, parse, and errors

* feat(verifier): thread screenshot + capture corpus sampler

* feat(verifier): LLM accessors — candidates, config, sampler

* test(verifier): cover AllCandidates, LLMConfig, SampleInput

* feat(trace): record action Source and LLMReasoning

* feat(runner): thread step screenshot into PushSnapshot

* feat(runner): llmSource selects actions via OpenRouter

* feat(runner): wire llmSource selection and trace stamping

* test(runner): cover llmSource selection, mapping, downscale

* docs(folio): add llm action-backend example spec

* docs(folio): document the LLM action backend run

* feat(llmclient): support OPENAI_API_KEY, openrouter wins

* refactor(runner): rename openrouter package to llmclient

* docs: both api keys, example model gpt-5.4-nano

* docs: add pr style rules to claude.md

* fix(runner): explain action kinds in llm prompt to stop swipe loops

* feat(trace): record llm ranked list and chosen rank

* feat(runner): stamp llm ranked list and chosen rank on trace

* fix(runner): tap by selector to survive layout shift after observe

* revert(runner): drop selector-first tap; broke path/testTag selectors

* feat(spec): llm() accepts optional instructions

* feat(verifier): read llm instructions off config

* feat(runner): append spec instructions to llm system prompt

* docs(folio): describe app in llm spec instructions

* feat(bundler): map generator export to globalThis.generator

* feat(verifier): read llm config off globalThis.generator

* feat(runner): gate llm source on --generator flag

* feat(cmd): add --generator llm|seeded flag

* test: cover --generator flag parsing and pickSources gating

* feat(verifier): enumerate llm candidates by walking actionsRoot

collect-walk the weighted action tree: recurse weighted branches
accumulating selection probability, call authored leaves once for
concrete actions, enumerate builtins per element. label controls by
visible text (borrowing descendant text), fold gestures into directional
scrolls over scrollable containers, drop disabled, dedup descriptions.

* test(verifier): cover candidate enumeration walk

* feat(verifier): add SetupAction to walk setup without the seeded root

* test(verifier): cover SetupAction setup-only precedence

* refactor(llmclient): make JSONSchema.Schema raw json for pinned field order

* feat(trace): record llm choice number and chosen_action echo

* feat(runner): llm picks one number from weighted candidates

drop the seeded-root call for a setup-only precedence path, render a
numbered weighted candidate list, pin a reasoning-first choice schema,
strict-skip when chosen_action does not echo the numbered entry, and let
the model supply typed values (corpus fallback when empty).

* test(runner): cover choice schema, strict-skip, and setup precedence

* refactor(verifier): drop the superseded AllCandidates enumeration

* feat(folio): drive spec.ts under --generator llm; drop spec-llm.ts

* fix(verifier): label editable fields by hint, not the typed value

an editable field's own text is its transient content; prefer the hint
so the field is named by purpose and the label stays stable.

* test(runner): cover weight-suffixed echo and stripWeightSuffix

* fix(runner): accept chosen_action echo that carries the weight suffix

real runs showed the model copies the whole numbered line including the
trailing (w34) weight annotation, so strict-skip rejected ~91% of picks
and the llm was paralyzed. strip the weight suffix before comparing. also
nudge the prompt to stress-test repeated submissions (idempotency).

* fix(verifier): skip llm enumeration on cross-fade frames

a navhost mid-transition carries >1 route *Screen in a collapsed
coordinate space; acting on it taps garbage (soft keyboard). real runs
showed the llm acting on 44% of steps being such frames. skip them so the
llm re-observes a settled frame next step.

* feat(folio): show current balance on the add-transaction screen

renders the account's balance (testTag TxnCurrentBalance) below the
account name, above the credit/debit toggle, so before/after screenshots
carry comparison data.

* fix(replay): derive device space from screen extent, not first node

the first positive-bounds element is often a short status-bar node
(320x24 on android); using it gave a 320/24 aspect ratio that squashed
the screenshot overlay into a grey horizontal band. use the max extent
across elements (like the runner's screenBounds) instead.

* fix(folio): show balance as a compact one-line label

per review: one line, account-name-sized, e.g. "Balance: $0.00"
instead of a large balance card.

* fix(folio): move balance into the header, one compact line under the account name

* fix(replay): attribute deferred violations to the causing step, not detection

* fix(replay): show a step's own violations in both panels, no next-step bleed

* refactor(hierarchy): one Tree.Transitional, drop the duplicated cross-fade check

* chore: ignore .playwright-mcp scratch output

* docs: document the llm generator and --generator flag

* docs(spec): correct the llm() comment; config reads off globalThis.generator

* docs: add pr description rules
2026-07-31 21:12:00 +05:30

170 lines
5.3 KiB
Go

// Package llmclient is a minimal client for the OpenAI-compatible
// chat-completions API, covering only what the LLM action backend needs: a
// single multimodal (text + one image) request per step with strict
// json_schema structured output. No streaming, tools, or other extras.
//
// The provider comes from the environment: OPENROUTER_API_KEY routes to
// OpenRouter, OPENAI_API_KEY to OpenAI; OpenRouter wins when both are set.
// Both speak the same wire format, so there is no provider-specific code.
package llmclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
openRouterBaseURL = "https://openrouter.ai/api/v1"
openAIBaseURL = "https://api.openai.com/v1"
)
// requestTimeout bounds a single chat-completion round-trip. Vision + strict
// structured output is slower than a plain text call, so this is generous; the
// runner also passes a context the caller can cancel.
const requestTimeout = 60 * time.Second
// Client talks to an OpenAI-compatible chat-completions endpoint.
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
}
// New builds a Client from the environment. OPENROUTER_API_KEY selects
// OpenRouter, OPENAI_API_KEY selects OpenAI; OpenRouter wins when both are
// set. OPENROUTER_BASE_URL / OPENAI_BASE_URL override the chosen provider's
// endpoint (tests, local OpenAI-compatible servers).
func New() (*Client, error) {
apiKey := os.Getenv("OPENROUTER_API_KEY")
baseURL := openRouterBaseURL
override := os.Getenv("OPENROUTER_BASE_URL")
if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY")
baseURL = openAIBaseURL
override = os.Getenv("OPENAI_BASE_URL")
}
if apiKey == "" {
return nil, errors.New("llmclient: neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set")
}
if override != "" {
baseURL = override
}
return &Client{
httpClient: &http.Client{Timeout: requestTimeout},
apiKey: apiKey,
baseURL: baseURL,
}, nil
}
// Request is a chat-completions request body. Only the fields the action
// backend sets are modeled.
type Request struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
}
// Message is one chat message. Content is always the array form (a list of
// parts), which OpenRouter accepts for every role.
type Message struct {
Role string `json:"role"`
Content []ContentPart `json:"content"`
}
// ContentPart is one piece of a message: either a text run or an image given
// as a data URL.
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL carries an image as a (typically data:) URL.
type ImageURL struct {
URL string `json:"url"`
}
// TextPart builds a text content part.
func TextPart(text string) ContentPart {
return ContentPart{Type: "text", Text: text}
}
// ImagePart builds an image content part from a data URL.
func ImagePart(dataURL string) ContentPart {
return ContentPart{Type: "image_url", ImageURL: &ImageURL{URL: dataURL}}
}
// ResponseFormat pins the model to a strict JSON schema.
type ResponseFormat struct {
Type string `json:"type"`
JSONSchema JSONSchema `json:"json_schema"`
}
// JSONSchema is the strict structured-output schema. Schema is raw JSON so the
// caller controls property ORDER: OpenAI emits fields in schema order, and a
// reasoning-first schema must not be re-sorted alphabetically (as a Go map
// would be).
type JSONSchema struct {
Name string `json:"name"`
Strict bool `json:"strict"`
Schema json.RawMessage `json:"schema"`
}
// Response is the slice of a chat-completions response we read.
type Response struct {
Choices []Choice `json:"choices"`
}
// Choice is one completion choice.
type Choice struct {
Message ResponseMessage `json:"message"`
}
// ResponseMessage carries the assistant's content. With json_schema output the
// content is a JSON string matching the schema.
type ResponseMessage struct {
Content string `json:"content"`
}
// ChatCompletion POSTs req to /chat/completions and decodes the response. A
// non-2xx status is returned as an error carrying the response body.
func (c *Client) ChatCompletion(ctx context.Context, req Request) (*Response, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("llmclient: marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("llmclient: build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("llmclient: request failed: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("llmclient: read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("llmclient: status %d: %s", resp.StatusCode, string(responseBody))
}
var out Response
if err := json.Unmarshal(responseBody, &out); err != nil {
return nil, fmt.Errorf("llmclient: decode response: %w", err)
}
return &out, nil
}