From 343c53c3618a31201129e5260106295610022d91 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 18:23:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(engine):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=95=86=E8=A7=A3=E8=80=A6=E4=B8=BA=20endpoint=E3=80=81model?= =?UTF-8?q?=20=E4=B8=8E=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置只保留 enabled、preset、endpoint、model、api_key 和 timeout_ms: preset 仅在 setup 时预填 endpoint/model,运行时不参与解析;删除品牌白名单、 "哪个环境变量存在就选哪个 provider" 的反推以及各品牌默认端点。环境变量收敛为 BERTH_ATTN_ENDPOINT / BERTH_ATTN_MODEL / BERTH_ATTN_KEY。 endpoint 允许 HTTPS 或 loopback HTTP;enabled 的配置必须给出 endpoint 和 model,否则是 invalid_config。attn test 使用解析后的有效字段探测。 验证:go build ./...;go test -count=1 -timeout=8m ./...;mage build; scripts/verify-attention-handoff.py --choice continue_without_action Co-Authored-By: Claude Code --- engine/cmd/jev-attention/main_test.go | 62 ++++---- engine/internal/attn/model/client.go | 31 ++-- engine/internal/attn/model/client_test.go | 5 +- engine/internal/attn/model/config.go | 58 ++++++-- engine/internal/attn/model/config_test.go | 4 +- engine/internal/attn/model/enable.go | 37 ++--- engine/internal/attn/model/enable_test.go | 11 +- engine/internal/attn/model/presets.go | 49 +++++++ engine/internal/attn/model/presets_test.go | 84 +++++++++++ engine/internal/attn/model/reason_test.go | 4 +- engine/internal/attn/model/resolve.go | 135 ++++++------------ engine/internal/attn/model/resolve_test.go | 70 +++++++++ engine/internal/attn/runner/runner_test.go | 10 +- engine/internal/cmd/attn_ctl.go | 54 ++++--- engine/internal/cmd/attn_test.go | 5 +- engine/internal/cmd/status_test.go | 2 +- engine/internal/doctor/check_attn.go | 4 +- engine/internal/doctor/check_attn_test.go | 8 +- .../scenario_attention_integration_test.go | 2 +- scripts/verify-attention-handoff.py | 3 +- 20 files changed, 414 insertions(+), 224 deletions(-) create mode 100644 engine/internal/attn/model/presets.go create mode 100644 engine/internal/attn/model/presets_test.go create mode 100644 engine/internal/attn/model/resolve_test.go diff --git a/engine/cmd/jev-attention/main_test.go b/engine/cmd/jev-attention/main_test.go index 87e36448..d653cb09 100644 --- a/engine/cmd/jev-attention/main_test.go +++ b/engine/cmd/jev-attention/main_test.go @@ -51,8 +51,9 @@ func TestLoadContextFileAndFlagOverrides(t *testing.T) { // permission. These tables pin the rule the old default* helpers encoded by // accident — environment keys used to switch model on by their mere presence. func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) { - enabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Provider: "openrouter", APIKey: "settings-key"} - disabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: false, Provider: "openrouter", APIKey: "settings-key"} + const endpoint, modelName = "https://assess.example/decisions", "test/model" + enabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Preset: "openrouter", Endpoint: endpoint, Model: modelName, APIKey: "settings-key"} + disabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: false, Preset: "openrouter", Endpoint: endpoint, Model: modelName, APIKey: "settings-key"} cases := []struct { name string input model.ResolveInput @@ -62,7 +63,7 @@ func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) { }{ { name: "environment key alone is not consent", - input: model.ResolveInput{OpenRouterKey: "env-key"}, + input: model.ResolveInput{Key: "env-key"}, wantEnabled: false, wantReason: model.ReasonDisabled, }, @@ -74,37 +75,37 @@ func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) { }, { name: "settings enabled falls back to the environment key", - input: model.ResolveInput{Settings: enabled, SettingsPresent: true, OpenRouterKey: "env-key"}, + input: model.ResolveInput{Settings: enabled, SettingsPresent: true, Key: "env-key"}, wantEnabled: true, wantKey: "env-key", }, { name: "explicitly disabled settings are not overridden by a key", - input: model.ResolveInput{Settings: disabled, SettingsPresent: true, OpenRouterKey: "env-key"}, + input: model.ResolveInput{Settings: disabled, SettingsPresent: true, Key: "env-key"}, wantEnabled: false, wantReason: model.ReasonDisabled, }, { name: "one-shot BERTH_ATTN is consent without a settings file", - input: model.ResolveInput{OneShot: "1", TypeSafeKey: "env-key"}, + input: model.ResolveInput{OneShot: "1", Endpoint: endpoint, Model: modelName, Key: "env-key"}, wantEnabled: true, wantKey: "env-key", }, { name: "BERTH_ATTN=0 is not consent", - input: model.ResolveInput{OneShot: "0", TypeSafeKey: "env-key"}, + input: model.ResolveInput{OneShot: "0", Endpoint: endpoint, Model: modelName, Key: "env-key"}, wantEnabled: false, wantReason: model.ReasonDisabled, }, { name: "enabled settings with no key anywhere reports no_key", - input: model.ResolveInput{Settings: model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Provider: "typesafe"}, SettingsPresent: true}, + input: model.ResolveInput{Settings: model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Endpoint: endpoint, Model: modelName}, SettingsPresent: true}, wantEnabled: true, wantReason: model.ReasonNoKey, }, { name: "a malformed settings file is a closed door, not a fallthrough", - input: model.ResolveInput{SettingsPresent: true, SettingsErr: errors.New("bad json"), OpenRouterKey: "env-key"}, + input: model.ResolveInput{SettingsPresent: true, SettingsErr: errors.New("bad json"), Key: "env-key"}, wantEnabled: false, wantReason: model.ReasonInvalidConfig, }, @@ -129,23 +130,24 @@ func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) { } func TestResolvedClientRefusesWithoutConsent(t *testing.T) { - withoutConsent := model.Resolved{Key: "k", Endpoint: model.DefaultEndpoint, Reason: model.ReasonDisabled} + withoutConsent := model.Resolved{Key: "k", Endpoint: "https://assess.example/decisions", Model: "test/model", Reason: model.ReasonDisabled} if _, err := withoutConsent.Client(); err == nil { t.Fatal("a disabled configuration produced a client") } - enabled := model.Resolved{Enabled: true, Key: "k", Endpoint: model.DefaultEndpoint, Model: model.DefaultModel, Timeout: time.Second} + enabled := model.Resolved{Enabled: true, Key: "k", Endpoint: "https://assess.example/decisions", Model: "test/model", Timeout: time.Second} client, err := enabled.Client() if err != nil { t.Fatalf("enabled configuration: %v", err) } - if client.APIKey != "k" || client.Endpoint != model.DefaultEndpoint { + if client.APIKey != "k" || client.Endpoint != "https://assess.example/decisions" { t.Fatalf("client = %+v", client) } } func TestRedactedStateNeverCarriesTheKey(t *testing.T) { resolved := model.Resolve(model.ResolveInput{Settings: model.LocalConfig{ - Schema: model.LocalConfigSchema, Enabled: true, Provider: "openrouter", APIKey: "secret-key", + Schema: model.LocalConfigSchema, Enabled: true, Preset: "openrouter", + Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "secret-key", }, SettingsPresent: true}) state := resolved.RedactedState() encoded, err := json.Marshal(state) @@ -160,24 +162,23 @@ func TestRedactedStateNeverCarriesTheKey(t *testing.T) { } } -func TestLocalJevSettingsSelectOpenRouterDefaults(t *testing.T) { +func TestLocalSettingsSupplyEndpointModelAndKey(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - t.Setenv("OPENROUTER_API_KEY", "") - t.Setenv("TYPESAFE_API_KEY", "") + t.Setenv("BERTH_ATTN_KEY", "") if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{ "schema":"oberth.attn-settings/v1", "enabled":true, - "provider":"openrouter", + "preset":"openrouter", "endpoint":"https://local.example/decisions", - "model":"local/jev", + "model":"local/model", "api_key":"local-key", "timeout_ms":5000 }`), 0o600); err != nil { t.Fatal(err) } got := resolveConsent("", false) - if !got.Enabled || got.Endpoint != "https://local.example/decisions" || got.Model != "local/jev" { + if !got.Enabled || got.Preset != "openrouter" || got.Endpoint != "https://local.example/decisions" || got.Model != "local/model" { t.Fatalf("resolved = %+v", got) } if got.Key != "local-key" || got.Timeout != 5*time.Second { @@ -188,28 +189,29 @@ func TestLocalJevSettingsSelectOpenRouterDefaults(t *testing.T) { } } -func TestEnvironmentKeySelectsTheProviderButNotConsent(t *testing.T) { +func TestEnvironmentFieldsSupplyEndpointAndModelButNotConsent(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - t.Setenv("OPENROUTER_API_KEY", "environment-key") - t.Setenv("OPENROUTER_ENDPOINT", "https://env.example/decisions") - t.Setenv("OPENROUTER_MODEL", "env/jev") - t.Setenv("TYPESAFE_API_KEY", "") + t.Setenv("BERTH_ATTN_KEY", "environment-key") + t.Setenv("BERTH_ATTN_ENDPOINT", "https://env.example/decisions") + t.Setenv("BERTH_ATTN_MODEL", "env/model") got := resolveConsent("", false) if got.Enabled { t.Fatalf("an environment key enabled the assessment: %+v", got) } - if got.Provider != model.ProviderOpenRouter || got.Endpoint != "https://env.example/decisions" || got.Model != "env/jev" { - t.Fatalf("provider selection = %+v", got) + if got.Endpoint != "https://env.example/decisions" || got.Model != "env/model" { + t.Fatalf("environment fields = %+v", got) + } + if got.Key != "" { + t.Fatalf("a disabled configuration leaked the key: %q", got.Key) } } func TestExplicitAPIKeyFlagWinsAndCanDisable(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - t.Setenv("OPENROUTER_API_KEY", "") - t.Setenv("TYPESAFE_API_KEY", "") - if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{"schema":"oberth.attn-settings/v1","enabled":true,"provider":"openrouter","api_key":"local-key"}`), 0o600); err != nil { + t.Setenv("BERTH_ATTN_KEY", "") + if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{"schema":"oberth.attn-settings/v1","enabled":true,"preset":"openrouter","endpoint":"https://assess.example/decisions","model":"test/model","api_key":"local-key"}`), 0o600); err != nil { t.Fatal(err) } if got := resolveConsent("", false); got.Key != "local-key" { @@ -244,7 +246,7 @@ func TestRunPreflightWritesFactRequestWhenModelUnavailable(t *testing.T) { } t.Setenv("BERTH_HOME", home) // No consent: the request must still be written, and no provider contacted. - disabled := model.Resolved{Endpoint: model.DefaultEndpoint, Model: model.DefaultModel, Timeout: time.Second, Reason: model.ReasonDisabled} + disabled := model.Resolved{Endpoint: "https://assess.example/decisions", Model: "test/model", Timeout: time.Second, Reason: model.ReasonDisabled} if err := runPreflight(statusPath, "", "restart_service", "api", "", "ask before restart", false, disabled, model.TaskContext{}); err != nil { t.Fatal(err) } diff --git a/engine/internal/attn/model/client.go b/engine/internal/attn/model/client.go index 40af7925..ecbe5410 100644 --- a/engine/internal/attn/model/client.go +++ b/engine/internal/attn/model/client.go @@ -15,7 +15,6 @@ import ( "io" "math" "net/http" - "net/url" "os" "path/filepath" "regexp" @@ -27,14 +26,10 @@ import ( ) const ( - DefaultEndpoint = "https://api.typesafe.ai/v1/systemone" - DefaultModel = "jev-latest" - DefaultOpenRouterEndpoint = "https://openrouter.ai/api/alpha/decisions" - DefaultOpenRouterModel = "typesafe/jev-1.13" - DefaultTimeout = 10 * time.Second - maxTaskBytes = 2000 - maxReasonBytes = 256 - maxServiceBytes = 128 + DefaultTimeout = 10 * time.Second + maxTaskBytes = 2000 + maxReasonBytes = 256 + maxServiceBytes = 128 ) // ErrUnavailable means that no assessment was made. Callers should leave the @@ -98,8 +93,8 @@ func (t TaskContext) validate(options []attn.Option) ([]string, error) { return FilterAuthorizedOptions(options, t.AuthorizedOptions) } -// Client calls the official TypeSafe System One endpoint. HTTPClient is -// injectable for tests; nil uses http.DefaultClient. +// Client calls one configured endpoint. HTTPClient is injectable for tests; +// nil uses http.DefaultClient. type Client struct { Endpoint string APIKey string @@ -113,15 +108,13 @@ func (c Client) normalized() (Client, error) { if c.APIKey == "" { return Client{}, WithReason(ReasonNoKey, ErrUnavailable) } - if strings.TrimSpace(c.Endpoint) == "" { - c.Endpoint = DefaultEndpoint + c.Endpoint = strings.TrimSpace(c.Endpoint) + c.Model = strings.TrimSpace(c.Model) + if c.Endpoint == "" || c.Model == "" { + return Client{}, WithReason(ReasonInvalidConfig, ErrUnavailable) } - u, err := url.Parse(c.Endpoint) - if err != nil || u.Scheme != "https" || u.Host == "" { - return Client{}, fmt.Errorf("attn: endpoint must be an HTTPS URL") - } - if strings.TrimSpace(c.Model) == "" { - c.Model = DefaultModel + if err := validateEndpoint(c.Endpoint); err != nil { + return Client{}, WithReason(ReasonInvalidConfig, fmt.Errorf("attn: %w", err)) } if c.Timeout <= 0 { c.Timeout = DefaultTimeout diff --git a/engine/internal/attn/model/client_test.go b/engine/internal/attn/model/client_test.go index a31ea34e..d5c0ab27 100644 --- a/engine/internal/attn/model/client_test.go +++ b/engine/internal/attn/model/client_test.go @@ -78,6 +78,7 @@ func TestAttnEvaluateRedactsStateAndPersistsOnlyValidatedAssessment(t *testing.T defer server.Close() result, err := (Client{ Endpoint: server.URL, + Model: "test/model", APIKey: "test-key", HTTPClient: server.Client(), }).Evaluate(context.Background(), artifact, TaskContext{ @@ -127,7 +128,7 @@ func TestAttnRejectsInvalidProbabilitiesAndUnauthorizedChoice(t *testing.T) { _, _ = w.Write([]byte(`{"model":"jev-latest","answers":{"task_relevant":{"type":"noul","noul":0.5},"needs_human":{"type":"noul","noul":0.5},"next_option":{"type":"choice","choice":"run_shell","probabilities":{"inspect_logs":1.2,"restart_service":-0.2},"confidence":1.2}}}`)) })) defer server.Close() - _, err := (Client{Endpoint: server.URL, APIKey: "k", HTTPClient: server.Client()}).Evaluate(context.Background(), artifact, TaskContext{Task: "fix", AuthorizedOptions: []string{"inspect_logs", "restart_service"}}) + _, err := (Client{Endpoint: server.URL, Model: "test/model", APIKey: "k", HTTPClient: server.Client()}).Evaluate(context.Background(), artifact, TaskContext{Task: "fix", AuthorizedOptions: []string{"inspect_logs", "restart_service"}}) if !errors.Is(err, ErrUnavailable) { t.Fatalf("err = %v, want unavailable validation error", err) } @@ -146,7 +147,7 @@ func TestAttnRejectsStaleArtifactBeforeRemoteCall(t *testing.T) { if !ok { t.Fatal("expected anomaly artifact") } - _, err := (Client{APIKey: "key"}).Evaluate(context.Background(), a, TaskContext{Task: "fix", AuthorizedOptions: []string{"inspect_logs"}}) + _, err := (Client{Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "key"}).Evaluate(context.Background(), a, TaskContext{Task: "fix", AuthorizedOptions: []string{"inspect_logs"}}) if !errors.Is(err, attn.ErrStale) { t.Fatalf("err = %v, want ErrStale", err) } diff --git a/engine/internal/attn/model/config.go b/engine/internal/attn/model/config.go index 7b42c75a..b185bfb9 100644 --- a/engine/internal/attn/model/config.go +++ b/engine/internal/attn/model/config.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "net" "net/url" "os" "runtime" @@ -28,13 +29,16 @@ const ( maxLocalTimeout = 2 * time.Minute ) -// LocalConfig is the on-disk shape written by the macOS settings page. +// LocalConfig is the on-disk shape written by `oberth attn config`. // APIKey is never included in telemetry, attention artifacts, or diagnostic // output. The file itself is required to be owner-only on Unix systems. +// +// Preset is a setup label only: it records which defaults filled Endpoint and +// Model. Runtime resolution never consults it. type LocalConfig struct { Schema string `json:"schema"` Enabled bool `json:"enabled"` - Provider string `json:"provider"` + Preset string `json:"preset,omitempty"` Endpoint string `json:"endpoint"` Model string `json:"model"` APIKey string `json:"api_key"` @@ -95,21 +99,27 @@ func (c *LocalConfig) Validate() error { if c.Schema != "" && c.Schema != LocalConfigSchema { return errors.New("attention settings schema is unsupported") } - c.Provider = strings.ToLower(strings.TrimSpace(c.Provider)) - if c.Provider == "" { - c.Provider = "openrouter" - } - if c.Provider != "openrouter" && c.Provider != "typesafe" { - return errors.New("attention settings provider is unsupported") + c.Preset = strings.ToLower(strings.TrimSpace(c.Preset)) + if c.Preset != "" { + if _, _, ok := PresetDefaults(c.Preset); !ok { + return fmt.Errorf("attention settings preset is unknown; use one of %s, or leave it empty", PresetNames()) + } } c.Endpoint = strings.TrimSpace(c.Endpoint) if c.Endpoint != "" { - u, err := url.Parse(c.Endpoint) - if err != nil || u.Scheme != "https" || u.Host == "" { - return errors.New("attention settings endpoint must be an HTTPS URL") + if err := validateEndpoint(c.Endpoint); err != nil { + return fmt.Errorf("attention settings: %w", err) } } c.Model = strings.TrimSpace(c.Model) + if c.Enabled { + if c.Endpoint == "" { + return errors.New("attention settings endpoint is required when the assessment is enabled") + } + if c.Model == "" { + return errors.New("attention settings model is required when the assessment is enabled") + } + } c.APIKey = strings.TrimSpace(c.APIKey) if len(c.APIKey) > maxLocalKey { return errors.New("attention settings API key is too long") @@ -123,6 +133,32 @@ func (c *LocalConfig) Validate() error { return nil } +// validateEndpoint accepts the two shapes the adapter can actually talk to: +// HTTPS anywhere, or plain HTTP on a loopback host for a local deployment. +func validateEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil || u.Host == "" { + return errors.New("endpoint must be an absolute URL") + } + if u.Scheme == "https" { + return nil + } + if u.Scheme == "http" && isLoopbackHost(u.Hostname()) { + return nil + } + return errors.New("endpoint must be HTTPS, or HTTP on a loopback host") +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + // Timeout returns the configured timeout or the adapter default. func (c LocalConfig) Timeout() time.Duration { if c.TimeoutMS == 0 { diff --git a/engine/internal/attn/model/config_test.go b/engine/internal/attn/model/config_test.go index d63ab2e8..a8f425d2 100644 --- a/engine/internal/attn/model/config_test.go +++ b/engine/internal/attn/model/config_test.go @@ -20,7 +20,7 @@ func TestLoadLocalConfigValidatesAndTrims(t *testing.T) { path := writeLocalConfig(t, `{ "schema":"oberth.attn-settings/v1", "enabled":true, - "provider":" OpenRouter ", + "preset":" OpenRouter ", "endpoint":"https://openrouter.example/decisions", "model":" typesafe/jev-1.13 ", "api_key":" sk-test ", @@ -30,7 +30,7 @@ func TestLoadLocalConfigValidatesAndTrims(t *testing.T) { if err != nil || !present { t.Fatalf("load = %+v, present=%v, err=%v", cfg, present, err) } - if !cfg.Enabled || cfg.Provider != "openrouter" || cfg.APIKey != "sk-test" { + if !cfg.Enabled || cfg.Preset != "openrouter" || cfg.APIKey != "sk-test" { t.Fatalf("normalized config = %+v", cfg) } if cfg.Timeout() != 12*time.Second { diff --git a/engine/internal/attn/model/enable.go b/engine/internal/attn/model/enable.go index 711a4bcd..9ceeda8e 100644 --- a/engine/internal/attn/model/enable.go +++ b/engine/internal/attn/model/enable.go @@ -43,7 +43,10 @@ const ( // EnableOptions is one `oberth attn enable` request. type EnableOptions struct { - Provider string + // Preset fills Endpoint and Model from a named pair of defaults. It is a + // convenience, not a provider restriction: explicit fields win, and an + // empty preset with explicit fields is a fully supported configuration. + Preset string Endpoint string Model string Timeout time.Duration @@ -60,11 +63,15 @@ func (o EnableOptions) SettingsFrom(cfg LocalConfig) LocalConfig { next := cfg next.Schema = LocalConfigSchema next.Enabled = true - if strings.TrimSpace(o.Provider) != "" { - next.Provider = strings.ToLower(strings.TrimSpace(o.Provider)) - } - if next.Provider == "" { - next.Provider = ProviderOpenRouter + if preset := strings.ToLower(strings.TrimSpace(o.Preset)); preset != "" { + next.Preset = preset + if endpoint, modelName, ok := PresetDefaults(preset); ok { + next.Endpoint, next.Model = endpoint, modelName + } + } else if strings.TrimSpace(o.Endpoint) != "" || strings.TrimSpace(o.Model) != "" { + // Explicit fields with no preset describe a custom deployment; keeping + // the old preset label would claim defaults that no longer apply. + next.Preset = "" } if strings.TrimSpace(o.Endpoint) != "" { next.Endpoint = strings.TrimSpace(o.Endpoint) @@ -140,8 +147,8 @@ func ProbeWithClient(ctx context.Context, cfg LocalConfig, httpClient *http.Clie } artifact := syntheticProbeArtifact() client := Client{ - Endpoint: firstNonEmpty(cfg.Endpoint, defaultEndpointFor(cfg.Provider)), - Model: firstNonEmpty(cfg.Model, defaultModelFor(cfg.Provider)), + Endpoint: cfg.Endpoint, + Model: cfg.Model, APIKey: cfg.APIKey, Timeout: cfg.Timeout(), HTTPClient: httpClient, @@ -158,20 +165,6 @@ func ProbeWithClient(ctx context.Context, cfg LocalConfig, httpClient *http.Clie return result } -func defaultEndpointFor(provider string) string { - if strings.EqualFold(provider, ProviderOpenRouter) { - return DefaultOpenRouterEndpoint - } - return DefaultEndpoint -} - -func defaultModelFor(provider string) string { - if strings.EqualFold(provider, ProviderOpenRouter) { - return DefaultOpenRouterModel - } - return DefaultModel -} - // syntheticProbeArtifact is a fixed, dated event so the probe request is a // valid assessment request without any local facts. func syntheticProbeArtifact() attn.Artifact { diff --git a/engine/internal/attn/model/enable_test.go b/engine/internal/attn/model/enable_test.go index 91274bf0..3a23c8b9 100644 --- a/engine/internal/attn/model/enable_test.go +++ b/engine/internal/attn/model/enable_test.go @@ -64,7 +64,8 @@ func TestWriteSettingsIsOwnerOnlyAndAtomic(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "nested", "attn.json") cfg := LocalConfig{ - Schema: LocalConfigSchema, Enabled: true, Provider: ProviderOpenRouter, + Schema: LocalConfigSchema, Enabled: true, Preset: "openrouter", + Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "test-key", TimeoutMS: 5000, } if err := WriteSettings(path, cfg); err != nil { @@ -96,7 +97,7 @@ func TestWriteSettingsIsOwnerOnlyAndAtomic(t *testing.T) { // An empty key is not a configuration: `enable` must not publish one. func TestWriteSettingsRefusesAnEmptyKey(t *testing.T) { path := filepath.Join(t.TempDir(), "attn.json") - if err := WriteSettings(path, LocalConfig{Schema: LocalConfigSchema, Enabled: true}); err == nil { + if err := WriteSettings(path, LocalConfig{Schema: LocalConfigSchema, Enabled: true, Endpoint: "https://assess.example/decisions", Model: "test/model"}); err == nil { t.Fatal("settings with an empty key were written") } if _, err := os.Stat(path); !os.IsNotExist(err) { @@ -106,7 +107,7 @@ func TestWriteSettingsRefusesAnEmptyKey(t *testing.T) { func TestDisableKeepsOrForgetsTheKey(t *testing.T) { dir := t.TempDir() - cfg := LocalConfig{Schema: LocalConfigSchema, Enabled: true, Provider: ProviderOpenRouter, APIKey: "kept-key"} + cfg := LocalConfig{Schema: LocalConfigSchema, Enabled: true, Preset: "openrouter", Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "kept-key"} kept := filepath.Join(dir, "kept.json") if err := DisableSettings(kept, cfg, false); err != nil { @@ -147,7 +148,7 @@ func TestProbeOnceSucceedsAndClassifiesFailures(t *testing.T) { "needs_human":{"type":"noul","noul":0.8}}}`)) })) defer good.Close() - cfg := LocalConfig{Provider: ProviderTypeSafe, Endpoint: good.URL, APIKey: "k", TimeoutMS: 3000} + cfg := LocalConfig{Endpoint: good.URL, Model: "test/model", APIKey: "k", TimeoutMS: 3000} result := ProbeWithClient(context.Background(), cfg, good.Client()) if !result.OK { t.Fatalf("probe failed: %+v", result) @@ -160,7 +161,7 @@ func TestProbeOnceSucceedsAndClassifiesFailures(t *testing.T) { w.WriteHeader(http.StatusUnauthorized) })) defer bad.Close() - result = ProbeWithClient(context.Background(), LocalConfig{Provider: ProviderTypeSafe, Endpoint: bad.URL, APIKey: "k", TimeoutMS: 3000}, bad.Client()) + result = ProbeWithClient(context.Background(), LocalConfig{Endpoint: bad.URL, Model: "test/model", APIKey: "k", TimeoutMS: 3000}, bad.Client()) if result.OK || result.Reason != ReasonUnauthorized { t.Fatalf("failed probe = %+v, want unauthorized", result) } diff --git a/engine/internal/attn/model/presets.go b/engine/internal/attn/model/presets.go new file mode 100644 index 00000000..5cf08132 --- /dev/null +++ b/engine/internal/attn/model/presets.go @@ -0,0 +1,49 @@ +package model + +// Presets are setup conveniences, not provider restrictions: they fill the +// endpoint and model fields when a person runs `oberth attn config --preset +// NAME`. Nothing at runtime consults a preset, and any endpoint that speaks the +// structured decisions protocol can be configured by giving endpoint and model +// directly. + +import "strings" + +// preset is one named pair of endpoint and model defaults. +type preset struct { + Name string + Endpoint string + Model string +} + +var presetCatalog = []preset{ + { + Name: "openrouter", + Endpoint: "https://openrouter.ai/api/alpha/decisions", + Model: "typesafe/jev-1.13", + }, + { + Name: "typesafe", + Endpoint: "https://api.typesafe.ai/v1/systemone", + Model: "jev-latest", + }, +} + +// PresetDefaults answers the endpoint and model a named preset fills in. +func PresetDefaults(name string) (endpoint, model string, ok bool) { + name = strings.ToLower(strings.TrimSpace(name)) + for _, preset := range presetCatalog { + if preset.Name == name { + return preset.Endpoint, preset.Model, true + } + } + return "", "", false +} + +// PresetNames lists the known names for help text and error hints. +func PresetNames() string { + names := make([]string, 0, len(presetCatalog)) + for _, preset := range presetCatalog { + names = append(names, preset.Name) + } + return strings.Join(names, ", ") +} diff --git a/engine/internal/attn/model/presets_test.go b/engine/internal/attn/model/presets_test.go new file mode 100644 index 00000000..e4af40ba --- /dev/null +++ b/engine/internal/attn/model/presets_test.go @@ -0,0 +1,84 @@ +package model + +import ( + "strings" + "testing" +) + +func TestEndpointValidationAcceptsHTTPSAndLoopbackHTTP(t *testing.T) { + for _, endpoint := range []string{ + "https://assess.example/decisions", + "http://localhost:8080/decisions", + "http://127.0.0.1:8080/decisions", + "http://[::1]:8080/decisions", + } { + if err := validateEndpoint(endpoint); err != nil { + t.Errorf("%s: %v", endpoint, err) + } + } + for _, endpoint := range []string{ + "http://assess.example/decisions", + "ftp://assess.example/decisions", + "/relative", + "", + } { + if err := validateEndpoint(endpoint); err == nil { + t.Errorf("%s was accepted", endpoint) + } + } +} + +func TestValidateRejectsUnknownPreset(t *testing.T) { + cfg := LocalConfig{ + Enabled: true, Preset: "acme", + Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "k", + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "preset") { + t.Fatalf("err = %v, want an unknown-preset error", err) + } +} + +func TestValidateRequiresEndpointAndModelWhenEnabled(t *testing.T) { + for _, cfg := range []LocalConfig{ + {Enabled: true, Model: "test/model", APIKey: "k"}, + {Enabled: true, Endpoint: "https://assess.example/decisions", APIKey: "k"}, + } { + if err := cfg.Validate(); err == nil { + t.Fatalf("incomplete config was accepted: %+v", cfg) + } + } + // A disabled file is allowed to be partial: it describes a decision, not a + // working configuration. + disabled := LocalConfig{Enabled: false} + if err := disabled.Validate(); err != nil { + t.Fatalf("disabled config rejected: %v", err) + } +} + +func TestPresetDefaultsFillOnlyWhatTheSetupLeftEmpty(t *testing.T) { + endpoint, modelName, ok := PresetDefaults(" OpenRouter ") + if !ok || endpoint == "" || modelName == "" { + t.Fatalf("preset defaults = %q %q %v", endpoint, modelName, ok) + } + if _, _, ok := PresetDefaults("acme"); ok { + t.Fatal("an unknown preset resolved defaults") + } + + filled := (EnableOptions{Preset: "openrouter"}).SettingsFrom(LocalConfig{}) + if filled.Endpoint != endpoint || filled.Model != modelName || filled.Preset != "openrouter" { + t.Fatalf("preset fill = %+v", filled) + } + + overridden := (EnableOptions{Preset: "openrouter", Endpoint: "https://custom.example/d", Model: "custom/model"}). + SettingsFrom(LocalConfig{}) + if overridden.Endpoint != "https://custom.example/d" || overridden.Model != "custom/model" { + t.Fatalf("explicit fields did not win: %+v", overridden) + } + + custom := (EnableOptions{Endpoint: "https://custom.example/d", Model: "custom/model"}). + SettingsFrom(LocalConfig{Preset: "openrouter", Endpoint: "https://old.example/d", Model: "old/model"}) + if custom.Preset != "" { + t.Fatalf("a custom endpoint kept the stale preset label: %+v", custom) + } +} diff --git a/engine/internal/attn/model/reason_test.go b/engine/internal/attn/model/reason_test.go index 4f1802d9..a2aaa485 100644 --- a/engine/internal/attn/model/reason_test.go +++ b/engine/internal/attn/model/reason_test.go @@ -123,7 +123,7 @@ func TestUnreachableProviderIsANetworkReason(t *testing.T) { server.Close() // nothing is listening any more _, artifact := testArtifact(t) - _, err := Client{Endpoint: url, APIKey: "k", HTTPClient: server.Client(), Timeout: time.Second}. + _, err := Client{Endpoint: url, Model: "test/model", APIKey: "k", HTTPClient: server.Client(), Timeout: time.Second}. Evaluate(context.Background(), artifact, TaskContext{Task: "why stuck", AuthorizedOptions: []string{"inspect_logs"}}) if got := ReasonFor(err); got != ReasonNetwork { t.Fatalf("reason = %q, want %q (%v)", got, ReasonNetwork, err) @@ -132,7 +132,7 @@ func TestUnreachableProviderIsANetworkReason(t *testing.T) { func TestNoKeyIsItsOwnReason(t *testing.T) { _, artifact := testArtifact(t) - _, err := Client{Endpoint: DefaultEndpoint}.Evaluate(context.Background(), artifact, + _, err := Client{Endpoint: "https://assess.example/decisions"}.Evaluate(context.Background(), artifact, TaskContext{Task: "why stuck", AuthorizedOptions: []string{"inspect_logs"}}) if got := ReasonFor(err); got != ReasonNoKey { t.Fatalf("reason = %q, want %q", got, ReasonNoKey) diff --git a/engine/internal/attn/model/resolve.go b/engine/internal/attn/model/resolve.go index 486abd6d..10efa350 100644 --- a/engine/internal/attn/model/resolve.go +++ b/engine/internal/attn/model/resolve.go @@ -2,13 +2,17 @@ package model // Consent resolution. One function decides every question the caller could // otherwise answer in scattered default* helpers: is the assessment enabled, -// which provider and endpoint, which key, and — when it cannot run — which -// fixed reason a caller may show. +// which endpoint and model, which key, and — when it cannot run — which fixed +// reason a caller may show. // // The rule this file encodes: an environment variable is a secret, not consent. // A key sitting in a shell profile is not the person saying "send this event to // a provider"; only `enabled: true` in the owner-only attn.json, or a single // one-shot BERTH_ATTN=1 for that process, is. +// +// Endpoint, model and key are three independent fields. Nothing here selects a +// provider: a preset only fills defaults while the setup command writes the +// file, and the environment overrides those same fields per process. import ( "os" @@ -25,7 +29,7 @@ const ( SourceNone Source = "" // SourceSettings is the owner-only attn.json the setup command writes. SourceSettings Source = "settings" - // SourceEnvironment is a provider key or endpoint from the environment. + // SourceEnvironment is a key, endpoint or model from the environment. SourceEnvironment Source = "environment" // SourceOneShot is BERTH_ATTN=1, consent for this process only. SourceOneShot Source = "one-shot" @@ -33,12 +37,6 @@ const ( SourceFlag Source = "flag" ) -// Provider names, matching the attn.json provider field. -const ( - ProviderOpenRouter = "openrouter" - ProviderTypeSafe = "typesafe" -) - // ResolveInput is everything the environment can contribute. Tests set it // directly; the command fills it from the process environment. Keeping it a // value instead of reading os.Getenv inside resolution is what makes the @@ -53,26 +51,20 @@ type ResolveInput struct { // SettingsErr is a malformed or unsafe settings file. It is reported as // invalid configuration, never silently ignored. SettingsErr error - // Environment provider keys. A non-empty key is what selects a provider. - OpenRouterKey string - OpenRouterURL string - OpenRouterMod string - TypeSafeKey string - TypeSafeURL string - TypeSafeMod string + // Endpoint, Model and Key override the stored fields for this process. + Endpoint string + Model string + Key string } // ResolveInputFromEnvironment reads the process environment. It never reads a // file, so an isolated run can point settings at its own path. func ResolveInputFromEnvironment() ResolveInput { return ResolveInput{ - OneShot: strings.TrimSpace(os.Getenv("BERTH_ATTN")), - OpenRouterKey: strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")), - OpenRouterURL: strings.TrimSpace(os.Getenv("OPENROUTER_ENDPOINT")), - OpenRouterMod: strings.TrimSpace(os.Getenv("OPENROUTER_MODEL")), - TypeSafeKey: strings.TrimSpace(os.Getenv("TYPESAFE_API_KEY")), - TypeSafeURL: strings.TrimSpace(os.Getenv("TYPESAFE_ENDPOINT")), - TypeSafeMod: strings.TrimSpace(os.Getenv("TYPESAFE_MODEL")), + OneShot: strings.TrimSpace(os.Getenv("BERTH_ATTN")), + Endpoint: strings.TrimSpace(os.Getenv("BERTH_ATTN_ENDPOINT")), + Model: strings.TrimSpace(os.Getenv("BERTH_ATTN_MODEL")), + Key: strings.TrimSpace(os.Getenv("BERTH_ATTN_KEY")), } } @@ -84,8 +76,9 @@ type Resolved struct { // ConsentSource says which declaration enabled it, so status can explain // why the assessment is on without exposing a key. ConsentSource Source - // Provider is the selected provider, empty when nothing was configured. - Provider string + // Preset is the setup label stored with the configuration, if any. It has + // no effect on resolution; endpoint and model are already explicit. + Preset string Endpoint string Model string // Key is the secret to send. It is never logged, copied into telemetry or @@ -115,7 +108,7 @@ const ( ReasonStale = "stale" ) -// oneShotConsent reports whether BERTH_JEV grants consent for this process. +// oneShotConsent reports whether BERTH_ATTN grants consent for this process. // Only an explicit affirmative counts; "0", "false" and any other value do not. func oneShotConsent(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { @@ -126,23 +119,20 @@ func oneShotConsent(value string) bool { } } -// Resolve applies the consent rule and picks one provider. A malformed -// settings file is a closed door with a reason, not a fall-through to -// whatever the environment happens to hold. +// Resolve applies the consent rule and returns the three explicit fields. A +// malformed settings file is a closed door with a reason, not a fall-through +// to whatever the environment happens to hold. func Resolve(in ResolveInput) Resolved { resolved := Resolved{Timeout: DefaultTimeout} settingsUsable := in.SettingsPresent && in.SettingsErr == nil if in.SettingsErr != nil { - resolved.Provider = ProviderTypeSafe - resolved.Endpoint = firstNonEmpty(in.TypeSafeURL, DefaultEndpoint) - resolved.Model = firstNonEmpty(in.TypeSafeMod, DefaultModel) resolved.Reason = ReasonInvalidConfig return resolved } - // Consent is decided first and on its own. Everything below this block is - // about which provider to use, never about whether it is allowed to run. + // Consent is decided first and on its own. Everything below is about which + // fields to use, never about whether the call is allowed to run. switch { case settingsUsable && in.Settings.Enabled: resolved.Enabled, resolved.ConsentSource = true, SourceSettings @@ -157,43 +147,28 @@ func Resolve(in ResolveInput) Resolved { resolved.Reason = ReasonDisabled } - provider := "" if settingsUsable { - provider = strings.ToLower(strings.TrimSpace(in.Settings.Provider)) - } - // The environment may name the provider by supplying its key. It does not - // grant consent, but once consent exists the key says which provider the - // secret belongs to. - if provider == "" { - switch { - case in.OpenRouterKey != "": - provider = ProviderOpenRouter - case in.TypeSafeKey != "": - provider = ProviderTypeSafe - } - } - if provider == "" && settingsUsable { - provider = strings.ToLower(strings.TrimSpace(in.Settings.Provider)) - } - resolved.Provider = provider - if resolved.Provider == "" { - resolved.Provider = ProviderTypeSafe - } - - resolved.Endpoint, resolved.Model = providerEndpointAndModel(resolved.Provider, in, settingsUsable) - if settingsUsable && in.Settings.Timeout() > 0 { + resolved.Preset = in.Settings.Preset + resolved.Endpoint = in.Settings.Endpoint + resolved.Model = in.Settings.Model resolved.Timeout = in.Settings.Timeout() } + resolved.Endpoint = firstNonEmpty(in.Endpoint, resolved.Endpoint) + resolved.Model = firstNonEmpty(in.Model, resolved.Model) // The key search is independent of consent so the closed door can be named // precisely. A key found without consent is then dropped from the value: a // disabled Resolved must not be able to leak a secret into a log line or a // JSON document, and the only thing it was needed for is the distinction // below. - key, keySource := pickKey(resolved.Provider, in, settingsUsable) + key, keySource := pickKey(in, settingsUsable) if resolved.Enabled { resolved.Key, resolved.KeySource = key, keySource + if resolved.Endpoint == "" || resolved.Model == "" { + resolved.Reason = ReasonInvalidConfig + return resolved + } if resolved.Key == "" { resolved.Reason = ReasonNoKey } @@ -214,40 +189,13 @@ func Resolve(in ResolveInput) Resolved { return resolved } -func providerEndpointAndModel(provider string, in ResolveInput, settingsUsable bool) (string, string) { - if provider == ProviderOpenRouter { - endpoint := DefaultOpenRouterEndpoint - model := DefaultOpenRouterModel - if settingsUsable && strings.EqualFold(in.Settings.Provider, ProviderOpenRouter) { - endpoint = firstNonEmpty(in.Settings.Endpoint, endpoint) - model = firstNonEmpty(in.Settings.Model, model) - } - return firstNonEmpty(in.OpenRouterURL, endpoint), firstNonEmpty(in.OpenRouterMod, model) - } - endpoint := DefaultEndpoint - model := DefaultModel - if settingsUsable && !strings.EqualFold(in.Settings.Provider, ProviderOpenRouter) { - endpoint = firstNonEmpty(in.Settings.Endpoint, endpoint) - model = firstNonEmpty(in.Settings.Model, model) - } - return firstNonEmpty(in.TypeSafeURL, endpoint), firstNonEmpty(in.TypeSafeMod, model) -} - // pickKey answers where the secret comes from. The environment wins over the // stored key, which is what CI and one-off isolated runs rely on. -func pickKey(provider string, in ResolveInput, settingsUsable bool) (string, Source) { - if provider == ProviderTypeSafe && in.TypeSafeKey != "" { - return in.TypeSafeKey, SourceEnvironment - } - if provider == ProviderOpenRouter && in.OpenRouterKey != "" { - return in.OpenRouterKey, SourceEnvironment +func pickKey(in ResolveInput, settingsUsable bool) (string, Source) { + if in.Key != "" { + return in.Key, SourceEnvironment } - switch { - case in.TypeSafeKey != "": - return in.TypeSafeKey, SourceEnvironment - case in.OpenRouterKey != "": - return in.OpenRouterKey, SourceEnvironment - case settingsUsable && strings.TrimSpace(in.Settings.APIKey) != "": + if settingsUsable && strings.TrimSpace(in.Settings.APIKey) != "" { return strings.TrimSpace(in.Settings.APIKey), SourceSettings } return "", SourceNone @@ -260,6 +208,9 @@ func (r Resolved) Client() (Client, error) { if !r.Enabled { return Client{}, ErrUnavailable } + if strings.TrimSpace(r.Endpoint) == "" || strings.TrimSpace(r.Model) == "" { + return Client{}, WithReason(ReasonInvalidConfig, ErrUnavailable) + } if strings.TrimSpace(r.Key) == "" { return Client{}, ErrUnavailable } @@ -271,13 +222,15 @@ func (r Resolved) Client() (Client, error) { func (r Resolved) RedactedState() map[string]any { state := map[string]any{ "enabled": r.Enabled, - "provider": r.Provider, "endpoint": r.Endpoint, "model": r.Model, "timeout_ms": r.Timeout.Milliseconds(), "key_source": string(r.KeySource), "consent_source": string(r.ConsentSource), } + if r.Preset != "" { + state["preset"] = r.Preset + } if r.Reason != "" { state["reason"] = r.Reason } diff --git a/engine/internal/attn/model/resolve_test.go b/engine/internal/attn/model/resolve_test.go new file mode 100644 index 00000000..94168147 --- /dev/null +++ b/engine/internal/attn/model/resolve_test.go @@ -0,0 +1,70 @@ +package model + +import "testing" + +func TestResolveEnvironmentOverridesEndpointModelAndKey(t *testing.T) { + settings := LocalConfig{ + Enabled: true, Preset: "openrouter", + Endpoint: "https://settings.example/decisions", Model: "settings/model", APIKey: "settings-key", + } + got := Resolve(ResolveInput{ + Settings: settings, SettingsPresent: true, + Endpoint: "http://127.0.0.1:8080/decisions", Model: "env/model", Key: "env-key", + }) + if !got.Enabled || got.Preset != "openrouter" { + t.Fatalf("resolved = %+v", got) + } + if got.Endpoint != "http://127.0.0.1:8080/decisions" || got.Model != "env/model" || got.Key != "env-key" { + t.Fatalf("environment did not win: %+v", got) + } + if got.KeySource != SourceEnvironment { + t.Fatalf("key source = %q", got.KeySource) + } +} + +func TestResolveEnabledWithoutEndpointOrModelIsInvalidConfig(t *testing.T) { + for _, settings := range []LocalConfig{ + {Enabled: true, Model: "test/model", APIKey: "k"}, + {Enabled: true, Endpoint: "https://assess.example/decisions", APIKey: "k"}, + } { + got := Resolve(ResolveInput{Settings: settings, SettingsPresent: true}) + if got.Reason != ReasonInvalidConfig { + t.Fatalf("incomplete enabled settings = %+v, want invalid_config", got) + } + if _, err := got.Client(); err == nil { + t.Fatal("an incomplete configuration produced a client") + } + } +} + +func TestResolveOneShotStillNeedsAllThreeFields(t *testing.T) { + got := Resolve(ResolveInput{OneShot: "1", Key: "k"}) + if got.Reason != ReasonInvalidConfig { + t.Fatalf("one-shot without endpoint/model = %+v, want invalid_config", got) + } + got = Resolve(ResolveInput{ + OneShot: "1", Endpoint: "https://assess.example/decisions", Model: "test/model", Key: "k", + }) + if !got.Enabled || got.Reason != "" || got.ConsentSource != SourceOneShot { + t.Fatalf("complete one-shot = %+v", got) + } +} + +func TestResolveRedactedStateCarriesNoProvider(t *testing.T) { + got := Resolve(ResolveInput{Settings: LocalConfig{ + Enabled: true, Preset: "openrouter", + Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "secret", + }, SettingsPresent: true}) + state := got.RedactedState() + if _, ok := state["provider"]; ok { + t.Fatal("redacted state still has a provider field") + } + if state["preset"] != "openrouter" || state["endpoint"] != "https://assess.example/decisions" || state["model"] != "test/model" { + t.Fatalf("redacted state = %+v", state) + } + for _, value := range state { + if text, ok := value.(string); ok && text == "secret" { + t.Fatal("redacted state leaked the key") + } + } +} diff --git a/engine/internal/attn/runner/runner_test.go b/engine/internal/attn/runner/runner_test.go index 83c1bed4..4d11bd54 100644 --- a/engine/internal/attn/runner/runner_test.go +++ b/engine/internal/attn/runner/runner_test.go @@ -139,7 +139,7 @@ func TestAutoNormalDoesNotCallModel(t *testing.T) { calls := 0 srv := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ })) defer srv.Close() - out, err := Auto(context.Background(), "unused", ".", model.Client{Endpoint: srv.URL, APIKey: "test", HTTPClient: srv.Client()}, model.TaskContext{Task: "edit docs"}, + out, err := Auto(context.Background(), "unused", ".", model.Client{Endpoint: srv.URL, Model: "test/model", APIKey: "test", HTTPClient: srv.Client()}, model.TaskContext{Task: "edit docs"}, func(context.Context, string, string) (Status, error) { return Status{}, nil }) if err != nil || out.Status != "clear" || calls != 0 { t.Fatalf("out=%+v err=%v calls=%d", out, err, calls) @@ -175,7 +175,7 @@ func TestAutoAssessesRefreshesCachesAndBindsTask(t *testing.T) { }}) })) defer srv.Close() - client := model.Client{Endpoint: srv.URL, APIKey: "test", HTTPClient: srv.Client()} + client := model.Client{Endpoint: srv.URL, Model: "test/model", APIKey: "test", HTTPClient: srv.Client()} read := func(context.Context, string, string) (Status, error) { reads++; return s, nil } task := model.TaskContext{Task: "fix api", AuthorizedOptions: authorizedFor(a)} out, err := Auto(context.Background(), "unused", ".", client, task, read) @@ -221,7 +221,7 @@ func TestAutoUnavailableKeepsFactsAndRequestsHuman(t *testing.T) { } })) defer srv.Close() - client := model.Client{Endpoint: srv.URL, APIKey: "test", HTTPClient: srv.Client()} + client := model.Client{Endpoint: srv.URL, Model: "test/model", APIKey: "test", HTTPClient: srv.Client()} if mode == "no_key" { client.APIKey = "" } @@ -397,7 +397,7 @@ func TestAutoSendsOnlyExplicitAuthorizedCandidates(t *testing.T) { _, _ = w.Write([]byte(`{"model":"jev-test","answers":{"task_relevant":{"type":"noul","noul":0.4},"needs_human":{"type":"noul","noul":0.2}}}`)) })) defer srv.Close() - out, err := Auto(context.Background(), "unused", ".", model.Client{Endpoint: srv.URL, APIKey: "test", HTTPClient: srv.Client()}, model.TaskContext{ + out, err := Auto(context.Background(), "unused", ".", model.Client{Endpoint: srv.URL, Model: "test/model", APIKey: "test", HTTPClient: srv.Client()}, model.TaskContext{ Task: "inspect api", AuthorizedOptions: []string{"inspect_logs"}, }, func(context.Context, string, string) (Status, error) { return s, nil }) if err != nil { @@ -449,7 +449,7 @@ func TestAutoIntersectsBroadKnownAuthorizationWithEventCandidates(t *testing.T) _, _ = w.Write([]byte(`{"model":"jev-test","answers":{"task_relevant":{"type":"noul","noul":0.4},"needs_human":{"type":"noul","noul":0.2}}}`)) })) defer srv.Close() - out, err := Auto(context.Background(), "unused", ".", model.Client{Endpoint: srv.URL, APIKey: "test", HTTPClient: srv.Client()}, model.TaskContext{ + out, err := Auto(context.Background(), "unused", ".", model.Client{Endpoint: srv.URL, Model: "test/model", APIKey: "test", HTTPClient: srv.Client()}, model.TaskContext{ Task: "inspect api", AuthorizedOptions: []string{absent, wanted}, }, func(context.Context, string, string) (Status, error) { return s, nil }) if err != nil { diff --git a/engine/internal/cmd/attn_ctl.go b/engine/internal/cmd/attn_ctl.go index f007ce76..f998782e 100644 --- a/engine/internal/cmd/attn_ctl.go +++ b/engine/internal/cmd/attn_ctl.go @@ -23,7 +23,7 @@ import ( ) var ( - attnEnableProvider string + attnEnablePreset string attnEnableEndpoint string attnEnableModel string attnEnableTimeout time.Duration @@ -36,10 +36,10 @@ var ( var attnStatusCmd = &cobra.Command{ Use: "status", - Short: "Show whether model is on, and where its settings come from", + Short: "Show whether the assessment is on, and where its settings come from", Long: "Report the resolved configuration without contacting a provider: on or off,\n" + - "which provider and endpoint, where the key came from, and the fixed reason when\n" + - "it cannot be used. The key itself is never printed, only a short fingerprint.", + "endpoint, model, where the key came from, and the fixed reason when it cannot\n" + + "be used. The key itself is never printed, only a short fingerprint.", Args: cobra.NoArgs, RunE: runAttnStatus, } @@ -47,11 +47,12 @@ var attnStatusCmd = &cobra.Command{ var attnEnableCmd = &cobra.Command{ Use: "enable", Short: "Store a key, prove it works, and turn the optional assessment on", - Long: "One step, in this order: choose a provider, read the key from standard input\n" + - "(--key-stdin), an environment variable (--key-env NAME), or a hidden terminal\n" + - "prompt; send one synthetic redacted event to prove the key answers; only then\n" + - "write the owner-only attn.json. A probe that fails writes nothing and prints the\n" + - "fixed reason, so a typo cannot leave a configuration that breaks every task.\n\n" + + Long: "One step, in this order: choose an endpoint and model (directly, or through a\n" + + "preset that fills both), read the key from standard input (--key-stdin), an\n" + + "environment variable (--key-env NAME), or a hidden terminal prompt; send one\n" + + "synthetic redacted event to prove the key answers; only then write the\n" + + "owner-only attn.json. A probe that fails writes nothing and prints the fixed\n" + + "reason, so a typo cannot leave a configuration that breaks every task.\n\n" + "The command finishes by printing exactly what will and will not be sent.", Args: cobra.NoArgs, RunE: runAttnEnable, @@ -68,7 +69,7 @@ var attnTestCmd = &cobra.Command{ var attnDisableCmd = &cobra.Command{ Use: "disable", - Short: "Stop using model but keep the stored key", + Short: "Stop using the assessment but keep the stored key", Long: "Turn the assessment off. The key stays in attn.json unless --forget is given, so\n" + "enabling again does not need it typed a second time. --forget removes it.", Args: cobra.NoArgs, @@ -86,10 +87,10 @@ var attnPreviewCmd = &cobra.Command{ } func init() { - attnEnableCmd.Flags().StringVar(&attnEnableProvider, "provider", "openrouter", - "Provider: openrouter or typesafe") - attnEnableCmd.Flags().StringVar(&attnEnableEndpoint, "endpoint", "", "Override the provider endpoint") - attnEnableCmd.Flags().StringVar(&attnEnableModel, "model", "", "Override the provider model") + attnEnableCmd.Flags().StringVar(&attnEnablePreset, "preset", "", + "Fill endpoint and model from a preset ("+model.PresetNames()+"); optional") + attnEnableCmd.Flags().StringVar(&attnEnableEndpoint, "endpoint", "", "Endpoint URL (required unless --preset fills it)") + attnEnableCmd.Flags().StringVar(&attnEnableModel, "model", "", "Model name (required unless --preset fills it)") attnEnableCmd.Flags().DurationVar(&attnEnableTimeout, "timeout", 0, "Request timeout (default 10s)") attnEnableCmd.Flags().BoolVar(&attnKeyStdinFlag, "key-stdin", false, "Read the key from the first line of standard input") @@ -135,7 +136,9 @@ func runAttnStatus(cmd *cobra.Command, _ []string) error { } else { fmt.Fprintf(&b, "Attention: off (%s)\n", firstNonEmptyString(consent.Reason, model.ReasonDisabled)) } - fmt.Fprintf(&b, " provider %s\n", consent.Provider) + if consent.Preset != "" { + fmt.Fprintf(&b, " preset %s\n", consent.Preset) + } fmt.Fprintf(&b, " endpoint %s\n", consent.Endpoint) fmt.Fprintf(&b, " model %s\n", consent.Model) fmt.Fprintf(&b, " key %s\n", keySourceLine(consent.KeySource, state["key_fingerprint"])) @@ -166,9 +169,15 @@ func runAttnEnable(cmd *cobra.Command, _ []string) error { current = model.LocalConfig{} } options := model.EnableOptions{ - Provider: attnEnableProvider, Endpoint: attnEnableEndpoint, Model: attnEnableModel, + Preset: attnEnablePreset, Endpoint: attnEnableEndpoint, Model: attnEnableModel, Timeout: attnEnableTimeout, KeyKind: model.KeyFromPrompt, } + // Validate the endpoint and model before asking for a secret: a person who + // forgot --preset should not type a key only to be told the shape is wrong. + next := options.SettingsFrom(current) + if err := next.Validate(); err != nil { + return usageError{err} + } switch { case attnKeyStdinFlag: options.KeyKind = model.KeyFromStdin @@ -188,7 +197,6 @@ func runAttnEnable(cmd *cobra.Command, _ []string) error { if err != nil { return err } - next := options.SettingsFrom(current) next.APIKey = key if err := next.Validate(); err != nil { return usageError{err} @@ -209,7 +217,7 @@ func runAttnEnable(cmd *cobra.Command, _ []string) error { return err } state := model.Resolved{ - Enabled: true, ConsentSource: model.SourceSettings, Provider: next.Provider, + Enabled: true, ConsentSource: model.SourceSettings, Preset: next.Preset, Endpoint: next.Endpoint, Model: next.Model, Key: next.APIKey, KeySource: model.SourceSettings, Timeout: next.Timeout(), } @@ -221,7 +229,7 @@ func runAttnEnable(cmd *cobra.Command, _ []string) error { "sends": model.SendsSummary(), }, func() string { return fmt.Sprintf("Attention: enabled (%s, key %s)\n %s\n\n%s", - next.Provider, keySourceLine(model.SourceSettings, model.KeyFingerprint(next.APIKey)), + next.Model, keySourceLine(model.SourceSettings, model.KeyFingerprint(next.APIKey)), path, model.HumanSendsSummary()) }) } @@ -235,9 +243,9 @@ func runAttnTest(cmd *cobra.Command, _ []string) error { if err != nil { return err } - if consent.KeySource == model.SourceEnvironment || consent.KeySource == model.SourceFlag { - cfg.APIKey = consent.Key - } + // The probe must use the same effective fields a real assessment would: + // environment overrides are already merged into the resolved consent. + cfg.Endpoint, cfg.Model, cfg.APIKey = consent.Endpoint, consent.Model, consent.Key if cfg.TimeoutMS == 0 { cfg.TimeoutMS = int(consent.Timeout / time.Millisecond) } @@ -245,7 +253,7 @@ func runAttnTest(cmd *cobra.Command, _ []string) error { defer cancel() probe := model.ProbeOnce(ctx, cfg) document := map[string]any{ - "status": "ok", "provider": consent.Provider, "model": probe.Model, + "status": "ok", "endpoint": consent.Endpoint, "model": probe.Model, "latency_ms": probe.Latency.Milliseconds(), } if !probe.OK { diff --git a/engine/internal/cmd/attn_test.go b/engine/internal/cmd/attn_test.go index 581f6243..aaecffa0 100644 --- a/engine/internal/cmd/attn_test.go +++ b/engine/internal/cmd/attn_test.go @@ -108,13 +108,12 @@ func TestAttnTaskContextFileAndFlagOverride(t *testing.T) { func TestDisabledConsentProducesNoClient(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - t.Setenv("OPENROUTER_API_KEY", "environment-only-key") - t.Setenv("TYPESAFE_API_KEY", "") + t.Setenv("BERTH_ATTN_KEY", "environment-only-key") t.Setenv("BERTH_ATTN", "") consent := attnResolvedConsent() if consent.Enabled { - t.Fatalf("an environment key enabled model: %+v", consent) + t.Fatalf("an environment key enabled the assessment: %+v", consent) } if _, err := consent.Client(); err == nil { t.Fatal("a disabled configuration produced a client") diff --git a/engine/internal/cmd/status_test.go b/engine/internal/cmd/status_test.go index 617544d0..d8eac2ce 100644 --- a/engine/internal/cmd/status_test.go +++ b/engine/internal/cmd/status_test.go @@ -22,7 +22,7 @@ func enableJev(t *testing.T) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } - body := `{"schema":"oberth.attn-settings/v1","enabled":true,"provider":"typesafe","api_key":"k"}` + body := `{"schema":"oberth.attn-settings/v1","enabled":true,"endpoint":"https://assess.example/decisions","model":"test/model","api_key":"k"}` if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } diff --git a/engine/internal/doctor/check_attn.go b/engine/internal/doctor/check_attn.go index 6f63f8be..2865df81 100644 --- a/engine/internal/doctor/check_attn.go +++ b/engine/internal/doctor/check_attn.go @@ -25,7 +25,7 @@ func checkAttn(_ context.Context, env *Env) rpc.DoctorCheck { "run `oberth attn status` to see the resolved state, then `oberth attn enable` to rewrite it") } // OneShot is deliberately empty: this row reports the committed setting, so - // an unrelated BERTH_JEV export cannot make doctor claim a provider is + // an unrelated BERTH_ATTN export cannot make doctor claim an endpoint is // configured. consent := model.Resolve(model.ResolveInput{Settings: cfg, SettingsPresent: present}) if !consent.Enabled { @@ -38,6 +38,6 @@ func checkAttn(_ context.Context, env *Env) rpc.DoctorCheck { "enabled in settings but no key resolved, so every assessment will report no_key", "run `oberth attn enable` to store a key, or `oberth attn test` to see the current reason") } - return ok("Attention: on · "+consent.Provider, + return ok("Attention: on · "+consent.Model, "endpoint "+consent.Endpoint+", key from "+string(consent.KeySource)) } diff --git a/engine/internal/doctor/check_attn_test.go b/engine/internal/doctor/check_attn_test.go index 1354dc33..253a9f49 100644 --- a/engine/internal/doctor/check_attn_test.go +++ b/engine/internal/doctor/check_attn_test.go @@ -35,12 +35,12 @@ func TestAttnCheckReportsOffWithoutFailing(t *testing.T) { } } -func TestAttnCheckReportsOnWithProvider(t *testing.T) { +func TestAttnCheckReportsOnWithModel(t *testing.T) { env := fakeEnv(t) - writeSettings(t, env, `{"schema":"oberth.attn-settings/v1","enabled":true,"provider":"openrouter","api_key":"k"}`, 0o600) + writeSettings(t, env, `{"schema":"oberth.attn-settings/v1","enabled":true,"endpoint":"https://assess.example/decisions","model":"test/model","api_key":"k"}`, 0o600) got := run(t, env, checkAttn) wantStatus(t, got, StatusOK) - if got.Summary != "Attention: on · openrouter" { + if got.Summary != "Attention: on · test/model" { t.Fatalf("summary = %q", got.Summary) } // The key must never appear in a doctor row. @@ -51,7 +51,7 @@ func TestAttnCheckReportsOnWithProvider(t *testing.T) { func TestAttnCheckWarnsWhenEnabledWithoutAKey(t *testing.T) { env := fakeEnv(t) - writeSettings(t, env, `{"schema":"oberth.attn-settings/v1","enabled":true,"provider":"typesafe"}`, 0o600) + writeSettings(t, env, `{"schema":"oberth.attn-settings/v1","enabled":true,"endpoint":"https://assess.example/decisions","model":"test/model"}`, 0o600) got := run(t, env, checkAttn) wantStatus(t, got, StatusWarn) if got.Fix == "" { diff --git a/engine/internal/scenario/scenario_attention_integration_test.go b/engine/internal/scenario/scenario_attention_integration_test.go index a65d157d..ff33e5ff 100644 --- a/engine/internal/scenario/scenario_attention_integration_test.go +++ b/engine/internal/scenario/scenario_attention_integration_test.go @@ -80,7 +80,7 @@ func enableJevIn(t *testing.T, e *env) { if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatalf("creating the model settings directory: %v", err) } - body := `{"schema":"oberth.attn-settings/v1","enabled":true,"provider":"typesafe","api_key":"integration-placeholder"}` + body := `{"schema":"oberth.attn-settings/v1","enabled":true,"endpoint":"https://assess.example/decisions","model":"test/model","api_key":"integration-placeholder"}` if err := os.WriteFile(filepath.Join(dir, "attn.json"), []byte(body), 0o600); err != nil { t.Fatalf("writing the model settings file: %v", err) } diff --git a/scripts/verify-attention-handoff.py b/scripts/verify-attention-handoff.py index 180d3724..544a9aa6 100755 --- a/scripts/verify-attention-handoff.py +++ b/scripts/verify-attention-handoff.py @@ -203,7 +203,8 @@ def main() -> int: settings = home / "attn.json" settings.write_text( '{"schema":"oberth.attn-settings/v1","enabled":true,' - '"provider":"typesafe","api_key":"verification-only"}\n' + '"preset":"typesafe","endpoint":"https://assess.example/decisions",' + '"model":"test/model","api_key":"verification-only"}\n' ) settings.chmod(0o600) From 95f81eca680052695f8006ba97c2253d9fac2a8e Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 18:23:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(agent):=20=E8=AF=B4=E6=98=8E=20endpoin?= =?UTF-8?q?t=E3=80=81model=E3=80=81key=20=E4=B8=8E=20preset=20=E7=9A=84?= =?UTF-8?q?=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 适配器文档与 cli 双语对去掉按品牌选 provider 的描述,改成三个独立字段、 通用环境变量与 loopback HTTP 规则;preset 明确为 setup 预填,不影响运行时。 验证:python3 scripts/check_docs.py Co-Authored-By: Claude Code --- docs/agent/attn-adapter.md | 57 ++++++++++++++++++++------------------ docs/cli.md | 4 +-- docs/cli.zh.md | 6 ++-- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/docs/agent/attn-adapter.md b/docs/agent/attn-adapter.md index 998660a4..0568556c 100644 --- a/docs/agent/attn-adapter.md +++ b/docs/agent/attn-adapter.md @@ -20,29 +20,35 @@ same object the agent already parsed: `engine/cmd/jev-attention` remains as a thin shell over the same runner package (`internal/attn/runner`) while existing scripts and the installed skill catch up. -The adapter supports TypeSafe's endpoint and OpenRouter's Jev Decisions API. -For OpenRouter, use `POST https://openrouter.ai/api/alpha/decisions` with the -`typesafe/jev-1.13` model. Both endpoints accept the same structured `state`, -model, and typed `questions`; responses contain typed `answers`. The adapter -uses two `noul` questions (`task_relevant`, `needs_human`) and a `choice` -question for the authorized fixed options. See the [OpenRouter Jev guide](https://openrouter.ai/blog/insights/what-is-jev/) +The adapter speaks one wire contract: a structured `state`, `model` and typed +`questions` request, answered with typed `answers`. TypeSafe's endpoint and +OpenRouter's Jev Decisions API both implement it; any other endpoint that +implements the same shape works too. Endpoint, model and key are independent +fields — the `openrouter` and `typesafe` presets only fill the first two while +you configure, and are never consulted at runtime. The adapter uses two `noul` +questions (`task_relevant`, `needs_human`) and a `choice` question for the +authorized fixed options. See the [OpenRouter Jev guide](https://openrouter.ai/blog/insights/what-is-jev/) and [TypeSafe API reference](https://docs.typesafe.ai/api/) for the upstream wire contract. ## Turning it on -`oberth attn enable` is the one step, in this order: choose a provider, read the -key, prove the key answers, then write the owner-only file. +`oberth attn enable` is the one step, in this order: give an endpoint and model +(directly, or through a preset that fills both), read the key, prove the key +answers, then write the owner-only file. ```sh # a script or CI job: the key never appears in an argument list -printf '%s' "$KEY" | oberth attn enable --provider openrouter --key-stdin +printf '%s' "$KEY" | oberth attn enable --preset openrouter --key-stdin # read it from an existing variable -oberth attn enable --provider typesafe --key-env TYPESAFE_API_KEY +oberth attn enable --preset typesafe --key-env ATTN_KEY + +# any compatible endpoint: no preset, explicit fields +oberth attn enable --endpoint https://assess.example/decisions --model team/model --key-env ATTN_KEY # a person at a terminal: hidden input -oberth attn enable --provider openrouter +oberth attn enable --preset openrouter ``` The proof is one synthetic, redacted assessment — the same request shape a real @@ -126,12 +132,10 @@ assessment handoffs, and `stopped` when the agent cancels the process. It polls `oberth status --json --no-mark`, so the daemon and core CLI remain model free and no hook or MCP registration is needed. -When `OPENROUTER_API_KEY` is present, the adapter selects -`https://openrouter.ai/api/alpha/decisions` and `typesafe/jev-1.13` -automatically. `OPENROUTER_ENDPOINT` and `OPENROUTER_MODEL` can override -those defaults. `TYPESAFE_API_KEY`, `TYPESAFE_ENDPOINT`, and `TYPESAFE_MODEL` -remain available for the native TypeSafe endpoint. Never put either provider's -key in a repository file or a task-context JSON file. +For CI and one-off runs the same three fields come from the environment: +`BERTH_ATTN_ENDPOINT`, `BERTH_ATTN_MODEL` and `BERTH_ATTN_KEY`. They override +the stored fields for that process only. Never put a key in a repository file or +a task-context JSON file. ### An environment key is a secret, not consent @@ -143,12 +147,11 @@ to be sent to a provider. Only two things turn the assessment on: | `"enabled": true` in the owner-only `attn.json` | every process that shares this `BERTH_HOME` | | `BERTH_ATTN=1` (`true`, `yes` or `on`) | this process only, for CI and one-off runs | -Everything else only supplies settings: `OPENROUTER_API_KEY` / `TYPESAFE_API_KEY` -and their endpoint/model variables pick the provider and the secret. An -`attn.json` that says `"enabled": false` is a decision the person already made, -so an environment key does not override it. One resolution function decides all -of this, and the setup command's `--endpoint`, `--model` and `--timeout` change -the field they name without granting consent. +Everything else only supplies settings: `BERTH_ATTN_ENDPOINT`, +`BERTH_ATTN_MODEL` and `BERTH_ATTN_KEY` change the field they name and never +grant consent. An `attn.json` that says `"enabled": false` is a decision the +person already made, so an environment key does not override it. One resolution +function decides all of this. ### Fixed reasons instead of provider text @@ -169,7 +172,7 @@ at `~/.option-berth/attn.json` (or `$BERTH_HOME/attn.json` in an isolated run): { "schema": "oberth.attn-settings/v1", "enabled": true, - "provider": "openrouter", + "preset": "openrouter", "endpoint": "https://openrouter.ai/api/alpha/decisions", "model": "typesafe/jev-1.13", "api_key": "…", @@ -179,9 +182,9 @@ at `~/.option-berth/attn.json` (or `$BERTH_HOME/attn.json` in an isolated run): The adapter reads this file automatically, so an agent does not need to re-export a shell variable for every task. This file is the only persistent -consent: an explicit `OPENROUTER_API_KEY` or `TYPESAFE_API_KEY` supplies the key -and selects the provider but does not enable the model, and `enabled: false` -keeps it off even when a key is present in the environment. The key never +consent: an environment key supplies the secret but does not enable the model, +and `enabled: false` keeps it off even when a key is present in the environment. +The key never appears in attention artifacts, quality telemetry, or daemon output. For one-off use, `--task` and `--authorize inspect_logs,restart_service` can diff --git a/docs/cli.md b/docs/cli.md index 11b61759..f58fa03c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -318,12 +318,12 @@ will not guess an authorization set on the agent's behalf. The adapter sends onl summary to the selected provider and does not read logs or start and stop services. The runner outputs `clear`, `continue`, `human_required`, `unavailable` or `stale`; on success it atomically writes the validated `assessment` back to the same file and re-reads the state before returning. When -there is no usable environment variable or GUI-local model configuration, or on network failure, +there is no usable environment endpoint, model or key, or on network failure, invalid response, expired file or changed state revision, the fact file is left unchanged. The `~/.option-berth/attn.json` written by `oberth attn enable` (`$BERTH_HOME/attn.json` for isolated runs) is read automatically by `oberth attn` and is the only persistent consent: `enabled: true` turns the model on, `enabled: false` keeps it off even when a key is present. An explicit -`OPENROUTER_API_KEY` / `TYPESAFE_API_KEY` supplies the key and selects the provider but does not +`BERTH_ATTN_ENDPOINT` / `BERTH_ATTN_MODEL` / `BERTH_ATTN_KEY` supplies those fields but does not enable the model; `BERTH_ATTN=1` enables it for one process. Each unavailable answer carries one fixed reason (`no_key`, `disabled`, `invalid_config`, `unauthorized`, `rate_limited`, `timeout`, `network`, `provider_error`, `bad_response`, `stale`) rather than a provider's error text. `--auto`, `--session` and diff --git a/docs/cli.zh.md b/docs/cli.zh.md index dfa32321..eed6ceb6 100644 --- a/docs/cli.zh.md +++ b/docs/cli.zh.md @@ -271,11 +271,11 @@ oberth attn assess --project "$PWD" \ 猜一个授权集。适配器只向选定的提供商发送脱敏的事实摘要, 不会读取日志或起停服务。runner 输出 `clear`、`continue`、`human_required`、`unavailable` 或 `stale`;成功时把校验后的 `assessment` 原子写回同一文件,并在返回前复读状态。 -没有可用的环境变量或 本地模型配置、网络失败、响应无效、文件过期或状态修订变化时,事实文件保持不变。 +没有可用的环境 endpoint、model 或 key、网络失败、响应无效、文件过期或状态修订变化时,事实文件保持不变。 `oberth attn enable` 写入的 `~/.option-berth/attn.json`(隔离运行时为 `$BERTH_HOME/attn.json`)由 `oberth attn` 自动读取,并且是唯一的持久同意:`enabled: true` 开启,`enabled: false` 即使环境 -里有密钥也保持关闭。显式 `OPENROUTER_API_KEY` / `TYPESAFE_API_KEY` 只提供密钥并选定 provider,不 -开启评估;`BERTH_ATTN=1` 只为当前进程开启。每次不可用都带固定原因码(`no_key`、`disabled`、 +里有密钥也保持关闭。显式 `BERTH_ATTN_ENDPOINT` / `BERTH_ATTN_MODEL` / `BERTH_ATTN_KEY` 只提供这三个 +字段,不开启评估;`BERTH_ATTN=1` 只为当前进程开启。每次不可用都带固定原因码(`no_key`、`disabled`、 `invalid_config`、`unauthorized`、`rate_limited`、`timeout`、`network`、`provider_error`、 `bad_response`、`stale`),而不是 provider 的错误原文。 `--auto`、`--session` 和 `--subscribe` 必须显式提供 `authorized_options`;artifact 的 `options` 只是候选集,