diff --git a/adapters/models/openai_compatible_local.go b/adapters/models/openai_compatible_local.go index a9dbc4b..501e3d6 100644 --- a/adapters/models/openai_compatible_local.go +++ b/adapters/models/openai_compatible_local.go @@ -32,14 +32,25 @@ func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (* // NewOpenAICompatibleLocalAdapterWithAPIKey creates an adapter for a gateway // whose bearer credential was resolved by the trusted runtime boundary. func NewOpenAICompatibleLocalAdapterWithAPIKey(raw, apiKey string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) { + return NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey, transport, 10*time.Second) +} + +// NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout creates a local gateway +// adapter with an explicit invocation deadline. The request context remains +// authoritative for per-call cancellation; this client deadline prevents a +// provider from holding a worker indefinitely. +func NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey string, transport http.RoundTripper, timeout time.Duration) (*OpenAICompatibleLocalAdapter, error) { base, err := url.Parse(raw) if err != nil || base.Scheme != "http" || base.Host == "" || strings.TrimSuffix(base.Path, "/") != "/v1" || base.RawQuery != "" || base.Fragment != "" || base.User != nil { return nil, errors.New("invalid local compatible endpoint") } + if timeout <= 0 { + return nil, errors.New("local compatible timeout must be positive") + } if transport == nil { transport = http.DefaultTransport } - return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, apiKey: strings.TrimSpace(apiKey)}, nil + return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, apiKey: strings.TrimSpace(apiKey)}, nil } type compatibleModelsResponse struct { diff --git a/adapters/models/openai_compatible_local_test.go b/adapters/models/openai_compatible_local_test.go index da58dfc..6ea7b06 100644 --- a/adapters/models/openai_compatible_local_test.go +++ b/adapters/models/openai_compatible_local_test.go @@ -6,12 +6,29 @@ import ( "net/http" "strings" "testing" + "time" ) type captureRoundTripper struct { authorization string } +func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) { + adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second) + if err != nil { + t.Fatal(err) + } + if adapter.client.Timeout != 47*time.Second { + t.Fatalf("unexpected client timeout: %s", adapter.client.Timeout) + } +} + +func TestOpenAICompatibleLocalAdapterRejectsNonPositiveTimeout(t *testing.T) { + if _, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", nil, 0); err == nil { + t.Fatal("expected non-positive timeout to be rejected") + } +} + func (t *captureRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { t.authorization = request.Header.Get("Authorization") return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), Header: make(http.Header), Request: request}, nil diff --git a/cmd/cognitive-benchmark/main.go b/cmd/cognitive-benchmark/main.go index bf6f54a..fe87b25 100644 --- a/cmd/cognitive-benchmark/main.go +++ b/cmd/cognitive-benchmark/main.go @@ -134,7 +134,7 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, observed tim artifact.Corpus = append(artifact.Corpus, corpusRecord{ID: item.id, Prompt: item.prompt, PromptSHA256: digest(item.prompt), Critical: item.critical}) } for _, provider := range plan.Providers { - adapter, err := newProvider(provider) + adapter, err := newProvider(provider, timeout) if err != nil { return benchmarkArtifact{}, err } @@ -158,10 +158,10 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, observed tim return artifact, nil } -func newProvider(candidate providerCandidate) (models.LocalProvider, error) { +func newProvider(candidate providerCandidate, timeout time.Duration) (models.LocalProvider, error) { switch candidate.ProviderKind { case "openai-compatible-chat-v1": - return models.NewOpenAICompatibleLocalAdapter(candidate.Endpoint, nil) + return models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(candidate.Endpoint, "", nil, timeout) case "ollama-v1": return models.NewOllamaAdapter(candidate.Endpoint, nil) default: diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index c8bfa38..210a338 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -69,7 +69,8 @@ func main() { if err != nil { log.Fatal(err) } - mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKey(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil) + mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 60*time.Second) + mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil, mediumTimeout) if adapterErr != nil { log.Fatal(adapterErr) } @@ -160,6 +161,19 @@ func main() { } } +func durationEnv(name string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + log.Printf("invalid %s=%q; using %s", name, value, fallback) + return fallback + } + return parsed +} + func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) { for name := range service.providers { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index e1c5748..0282027 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000174-medium-qualification-evidence.json", - "task_id": "DEV-000174", + "task_file": "dev/tasks/DEV-000175-medium-invocation-timeout.json", + "task_id": "DEV-000175", "task_revision": 1 } diff --git a/dev/tasks/DEV-000175-medium-invocation-timeout.json b/dev/tasks/DEV-000175-medium-invocation-timeout.json new file mode 100644 index 0000000..0cff790 --- /dev/null +++ b/dev/tasks/DEV-000175-medium-invocation-timeout.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000175", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "b29f8775e1217506098d5c9a0e99c1046494cba3", + "risk_tier": "E", + "intent": "Make local OpenAI-compatible provider invocation deadlines explicit per provider so slow but reachable fleet members can be qualified through the existing adapter path.", + "requirement_ids": ["WP-200", "WP-301", "AUDIT-COMPLETION-TRUTH"], + "allowed_change_scope": {"modules": ["cognitive adapter", "cognitive worker", "benchmark runtime"], "paths": ["adapters/models/openai_compatible_local.go", "adapters/models/openai_compatible_local_test.go", "cmd/cognitive-worker/main.go", "cmd/cognitive-benchmark/main.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000175-medium-invocation-timeout.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "contracts/**", "modules/**", "adapters/postgres/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["existing provider-neutral cognitive adapter contract", "existing provider environment configuration"], "may_change": ["provider-specific invocation timeout configuration"]}, + "semantic_owner": "cognitive adapter/runtime", + "canonical_state": ["adapter request deadline"], + "state_transitions": ["fixed local adapter deadline -> explicit positive provider-specific deadline"], + "side_effects_and_privileges": ["none beyond existing provider requests"], + "persistence_and_migrations": ["none"], + "failure_retry_restart": ["non-positive configuration falls back to safe default; request context remains authoritative"], + "assumptions": ["the existing Medium runtime is reachable but may require more than 10 seconds for a complete response", "default CPU behavior remains 10 seconds unless separately configured"], + "acceptance_scenarios": ["adapter accepts positive explicit timeout", "adapter rejects non-positive timeout", "benchmark passes its case timeout to the adapter", "production Medium defaults to 60 seconds", "no provider call bypasses the adapter"], + "required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl/independent", "git diff --check", "CI"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +}