Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
b83c32c
test: a provider's 401 reaches the material intake as a refusal in wo…
futurebuildai Oct 14, 2026
57eb9e6
test: a provider's 401 reaches PIM generate as a refusal in words (red)
futurebuildai Oct 14, 2026
7d130ba
test: saving an AI key tests it and the document reads the last test …
futurebuildai Oct 14, 2026
55732df
test: the illustration prompt asks for no words, labels, brand names …
futurebuildai Oct 14, 2026
8c7aecf
test: the sample list, its tests and the import hold no brand word (red)
futurebuildai Oct 14, 2026
90baa91
test: a text list is read by rules when the provider refuses the key …
futurebuildai Oct 14, 2026
5502824
test: Tech Admin says AI on only after a passed test (red)
futurebuildai Oct 14, 2026
28f11e5
test: the intake says the provider refused the key and shows no sampl…
futurebuildai Oct 14, 2026
89bef48
test: PIM generate shows the provider's refusal on the page (red)
futurebuildai Oct 14, 2026
635536c
AI: a provider's refusal of the saved key is answered in words
futurebuildai Oct 14, 2026
039db59
AI settings: saving a key tests it and the document reads the last test
futurebuildai Oct 14, 2026
e8190d0
AI: no words or brands in a drawing, plain names in the sample list a…
futurebuildai Oct 14, 2026
ef90624
desk: the provider's refusal in words, and AI on only after a passed …
futurebuildai Oct 14, 2026
b5172c5
contract: assemble openapi.yaml from the AI fragments
futurebuildai Oct 14, 2026
fe14a36
e2e: a placeholder AI key saved in Tech Admin reads off, not active
futurebuildai Oct 14, 2026
ab73248
test: freight invoice reading answers a provider's 401 in words (red)
futurebuildai Oct 14, 2026
14d4ed4
test: a refused key settles the image row refused, not failed (red)
futurebuildai Oct 14, 2026
dcf9d6c
test: OpenRouter's 403 is not a key refusal, an unreachable provider …
futurebuildai Oct 14, 2026
137518f
test: the sanitiser drops a model's text and tspan with their words (…
futurebuildai Oct 14, 2026
7d2a027
test: the extraction prompt and the tax test hold no brand word (red)
futurebuildai Oct 14, 2026
f67c7b8
test: no product description in the import names a maker (red)
futurebuildai Oct 14, 2026
44aaf6f
test: a stored test result is tied to its revision, an unreachable sa…
futurebuildai Oct 14, 2026
73bb2dc
test: no end to end spec names a third party host (red)
futurebuildai Oct 14, 2026
1f237d9
test: a refused image row reads in words, a refused key dims the AI f…
futurebuildai Oct 14, 2026
7430ae7
freight: a refused key is answered in words, and a refused read keeps…
futurebuildai Oct 14, 2026
8ddab6c
pim: a refused key settles the image row refused and the page says so
futurebuildai Oct 14, 2026
5aef8bf
AI: only a 401 refuses OpenRouter's key, a save's test names its revi…
futurebuildai Oct 14, 2026
feda70e
pim: the sanitiser drops a model's text and tspan
futurebuildai Oct 14, 2026
6d059f7
seed: product descriptions name no maker, the tax test names no brand
futurebuildai Oct 14, 2026
7963413
desk: the AI feature grid follows a passed test, e2e reaches no third…
futurebuildai Oct 14, 2026
7af7c10
contract: freight 502, refused media status, generated types, the rou…
futurebuildai Oct 14, 2026
5f5186c
Merge refactor/v1 into refactor/g1-ai-words
futurebuildai Oct 14, 2026
01e0474
purchase_order: plain punctuation in the moved freight comment
futurebuildai Oct 14, 2026
bddc0eb
test: out of order tests keep the last key's result, a pressed test i…
futurebuildai Oct 14, 2026
eef3a13
test: the refusal notice stands only while the newest attempt is refu…
futurebuildai Oct 14, 2026
e285790
AI settings: a test is stored against the revision it started on, and…
futurebuildai Oct 14, 2026
2d91cc4
desk: the refusal notice stands only while the newest attempt is refused
futurebuildai Oct 14, 2026
b8e1906
contract: the round's row
futurebuildai Oct 14, 2026
d4ffa92
Merge refactor/v1 into refactor/g1-ai-words
futurebuildai Oct 15, 2026
525f33e
test: attempts are ordered by the moment, not the timestamp text (red)
futurebuildai Oct 15, 2026
d9a1952
desk: the media tab orders attempts by the parsed moment
futurebuildai Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions core/api/fragments/_shared.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,17 @@ components:
application/json:
schema:
$ref: "#/components/schemas/WireError"
AIRefused:
description: >-
The AI provider refused the saved key (code ai_refused; a 401 or 403
from the provider). It is not AI off: a key is saved and was not
accepted. The message names the provider and says an administrator
can enter the key again in Tech Admin, AI Settings. Nothing answers a
canned result for it. ADR 0001 envelope.
content:
application/json:
schema:
$ref: "#/components/schemas/WireError"
AIOffEither:
description: >-
AI is off (code ai_off): no key for the chosen provider, a stored key
Expand Down
17 changes: 15 additions & 2 deletions core/api/fragments/admin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ components:
$ref: "#/components/schemas/ApiKey"
AISettings:
type: object
required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present]
required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present, test_status]
description: >-
Go type techadmin.AISettings. source is admin (a stored override),
env (a key from the environment only) or none. key_hint and base_url
Expand Down Expand Up @@ -779,15 +779,28 @@ components:
type: [string, "null"]
vault_present:
type: boolean
test_status:
type: string
enum: [none, untested, passed, refused, failed]
description: >-
The last test of the saved key: none with no key saved, untested
for a key never tested, passed, refused (the provider refused the
key) or failed (the test could not finish). A save runs the test.
test_message:
type: string
description: The plain sentence for refused and failed; absent otherwise.
AdminTestAIResult:
type: object
required: [ok, message]
required: [ok, message, status]
description: Go type techadmin.TestResult. A provider failure is ok false with its words, never the key.
properties:
ok:
type: boolean
message:
type: string
status:
type: string
enum: [passed, refused, failed]
RoutingSettings:
type: object
required: [configured, source, key_hint, revision]
Expand Down
1 change: 1 addition & 0 deletions core/api/fragments/parsing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ paths:
"415": { $ref: "#/components/responses/UnsupportedType" }
"422": { $ref: "#/components/responses/UnprocessableEntity" }
"500": { $ref: "#/components/responses/InternalError" }
"502": { $ref: "#/components/responses/AIRefused" }
components:
schemas:
ParsingParseResponse:
Expand Down
11 changes: 11 additions & 0 deletions core/api/fragments/product.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,8 @@ paths:
$ref: '#/components/responses/PayloadTooLarge'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'502':
$ref: '#/components/responses/AIRefused'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/products/{id}/pim/generate/descriptions:
Expand Down Expand Up @@ -477,6 +479,8 @@ paths:
$ref: '#/components/responses/PayloadTooLarge'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'502':
$ref: '#/components/responses/AIRefused'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/products/{id}/pim/generate/image:
Expand Down Expand Up @@ -570,6 +574,8 @@ paths:
$ref: '#/components/responses/PayloadTooLarge'
'422':
$ref: '#/components/responses/UnprocessableEntity'
'502':
$ref: '#/components/responses/AIRefused'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/products/{id}/margins:
Expand Down Expand Up @@ -1482,6 +1488,11 @@ components:
- generating
- ready
- failed
- refused
description: >-
refused is a generation the provider refused for the saved key
(a retry cannot work; an administrator enters the key again in
Tech Admin, AI Settings).
gen_model:
type: string
gen_prompt:
Expand Down
1 change: 1 addition & 0 deletions core/api/fragments/purchase_order.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ paths:
"413": { $ref: "#/components/responses/PayloadTooLarge" }
"415": { $ref: "#/components/responses/UnsupportedType" }
"500": { $ref: "#/components/responses/InternalError" }
"502": { $ref: "#/components/responses/AIRefused" }
"409": { $ref: "#/components/responses/ConflictEither" }
"422":
$ref: "#/components/responses/UnprocessableEntity"
Expand Down
33 changes: 31 additions & 2 deletions core/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9085,6 +9085,7 @@ paths:
"415": {$ref: "#/components/responses/UnsupportedType"}
"422": {$ref: "#/components/responses/UnprocessableEntity"}
"500": {$ref: "#/components/responses/InternalError"}
"502": {$ref: "#/components/responses/AIRefused"}
/api/partner/v1/dashboard:
get:
operationId: partnerDashboard
Expand Down Expand Up @@ -12955,6 +12956,8 @@ paths:
$ref: '#/components/responses/UnprocessableEntity'
'500':
$ref: '#/components/responses/InternalError'
'502':
$ref: '#/components/responses/AIRefused'
/api/v1/products/{id}/pim/generate/descriptions:
post:
operationId: productPimGenerateDescriptions
Expand Down Expand Up @@ -12998,6 +13001,8 @@ paths:
$ref: '#/components/responses/UnprocessableEntity'
'500':
$ref: '#/components/responses/InternalError'
'502':
$ref: '#/components/responses/AIRefused'
/api/v1/products/{id}/pim/generate/image:
post:
operationId: productPimGenerateImage
Expand Down Expand Up @@ -13085,6 +13090,8 @@ paths:
$ref: '#/components/responses/UnprocessableEntity'
'500':
$ref: '#/components/responses/InternalError'
'502':
$ref: '#/components/responses/AIRefused'
/api/v1/products/{id}/margins:
patch:
operationId: productUpdateMargins
Expand Down Expand Up @@ -13870,6 +13877,7 @@ paths:
"422":
$ref: "#/components/responses/UnprocessableEntity"
"500": {$ref: "#/components/responses/InternalError"}
"502": {$ref: "#/components/responses/AIRefused"}
/api/v1/purchase-orders/{id}/freight/{freightId}/apply:
post:
operationId: purchaseOrderFreightApply
Expand Down Expand Up @@ -15798,6 +15806,13 @@ components:
application/json:
schema:
$ref: "#/components/schemas/WireError"
AIRefused:
description: >-
The AI provider refused the saved key (code ai_refused; a 401 or 403 from the provider). It is not AI off: a key is saved and was not accepted. The message names the provider and says an administrator can enter the key again in Tech Admin, AI Settings. Nothing answers a canned result for it. ADR 0001 envelope.
content:
application/json:
schema:
$ref: "#/components/schemas/WireError"
AIOffEither:
description: >-
AI is off (code ai_off): no key for the chosen provider, a stored key the vault cannot open, or a feature the chosen provider does not offer. The message says that an administrator turns it on in Tech Admin, AI. Or the idempotency layer reports the Idempotency-Key in progress (code idempotency_in_progress). Both are the ADR 0001 envelope.
Expand Down Expand Up @@ -16578,7 +16593,7 @@ components:
$ref: "#/components/schemas/ApiKey"
AISettings:
type: object
required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present]
required: [configured, source, key_hint, base_url, revision, provider, model, anthropic_configured, anthropic_key_hint, vault_present, test_status]
description: >-
Go type techadmin.AISettings. source is admin (a stored override), env (a key from the environment only) or none. key_hint and base_url are null when absent, never omitted.
properties:
Expand All @@ -16604,15 +16619,26 @@ components:
type: [string, "null"]
vault_present:
type: boolean
test_status:
type: string
enum: [none, untested, passed, refused, failed]
description: >-
The last test of the saved key: none with no key saved, untested for a key never tested, passed, refused (the provider refused the key) or failed (the test could not finish). A save runs the test.
test_message:
type: string
description: The plain sentence for refused and failed; absent otherwise.
AdminTestAIResult:
type: object
required: [ok, message]
required: [ok, message, status]
description: Go type techadmin.TestResult. A provider failure is ok false with its words, never the key.
properties:
ok:
type: boolean
message:
type: string
status:
type: string
enum: [passed, refused, failed]
RoutingSettings:
type: object
required: [configured, source, key_hint, revision]
Expand Down Expand Up @@ -25830,6 +25856,9 @@ components:
- generating
- ready
- failed
- refused
description: >-
refused is a generation the provider refused for the saved key (a retry cannot work; an administrator enters the key again in Tech Admin, AI Settings).
gen_model:
type: string
gen_prompt:
Expand Down
5 changes: 4 additions & 1 deletion core/internal/ai/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func (c *Client) anthropicMessages(ctx context.Context, req anthropicRequest) (*

resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("Anthropic request failed: %s", scrub(err.Error()))
return nil, &UnreachableError{Provider: "Anthropic", Detail: "Anthropic request failed: " + scrub(err.Error())}
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
Expand All @@ -196,6 +196,9 @@ func (c *Client) anthropicMessages(ctx context.Context, req anthropicRequest) (*
if parsed && ar.Error != nil && ar.Error.Message != "" {
msg = ar.Error.Message
}
if refused := refusedIfAuth("Anthropic", resp.StatusCode, scrub(msg)); refused != nil {
return nil, refused
}
return nil, fmt.Errorf("Anthropic API error (%d): %s", resp.StatusCode, scrub(msg))
}
if !parsed {
Expand Down
7 changes: 5 additions & 2 deletions core/internal/ai/openrouter.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ func (c *Client) chatCompletion(ctx context.Context, req chatRequest) (*chatResp

resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("AI request failed: %s", providerWords(err.Error(), apiKey))
return nil, &UnreachableError{Provider: "OpenRouter", Detail: "AI request failed: " + providerWords(err.Error(), apiKey)}
}
defer resp.Body.Close()

Expand All @@ -523,6 +523,9 @@ func (c *Client) chatCompletion(ctx context.Context, req chatRequest) (*chatResp
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != nil {
msg = errResp.Error.Message
}
if refused := refusedIfAuth("OpenRouter", resp.StatusCode, providerWords(msg, apiKey)); refused != nil {
return nil, refused
}
return nil, fmt.Errorf("OpenRouter API error (%d): %s", resp.StatusCode, providerWords(msg, apiKey))
}

Expand Down Expand Up @@ -717,7 +720,7 @@ Example output:
50 pcs - 2x4x8 SPF Stud
25 pcs - 2x6x12 Doug Fir #2
30 sheets - OSB 7/16 4x8
20 bags - Quikrete 80lb`
20 bags - Concrete mix 80lb`

// ExtractMaterialList sends a file to the vision model for material list
// extraction. Supports images, PDFs, and pre-processed text/csv. Returns the raw
Expand Down
98 changes: 98 additions & 0 deletions core/internal/ai/refused.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: LicenseRef-OpenLBM-Commons-1.0
// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors

package ai

import (
"errors"
"fmt"
"net/http"
)

// CodeAIRefused is the stable wire code every AI feature answers when the
// provider refused the saved key (a 401 or 403 from the provider). It is not
// AI off: a key is saved, and the provider did not accept it. Clients branch
// on the code, never on the message.
const CodeAIRefused = "ai_refused"

// ErrKeyRefused is the sentinel every provider refusal matches (errors.Is).
var ErrKeyRefused = errors.New("the AI provider refused the saved key")

// RefusedError is a provider's refusal of the saved key. Message is the plain
// sentence a person reads (RefusedMessage); Error() is the provider's own
// words for logs and the admin's detail, scrubbed of the key before it lands.
type RefusedError struct {
Provider string
Message string
Detail string
}

func (e *RefusedError) Error() string { return e.Detail }
func (e *RefusedError) Is(target error) bool { return target == ErrKeyRefused }

// refusalWords is what a person reads when provider (a display name) refused
// the saved key.
func refusalWords(provider string) string {
return fmt.Sprintf("%s refused the saved key. An administrator can enter it again in Tech Admin, AI Settings.", provider)
}

// refusedIfAuth answers a RefusedError for a provider answer that means the
// key is not accepted (401 unauthorized, 403 forbidden), and nil otherwise.
func refusedIfAuth(provider string, status int, detail string) error {
// A 401 is a refusal of the key from either provider. A 403 is one from
// Anthropic (a permission_error is a key problem); from OpenRouter it means
// the model's moderation flagged the input and the key is fine.
if status != http.StatusUnauthorized && !(status == http.StatusForbidden && provider == "Anthropic") {
return nil
}
return &RefusedError{Provider: provider, Message: refusalWords(provider), Detail: fmt.Sprintf("%s API error (%d): %s", provider, status, detail)}
}

// RefusedMessage reports whether err is a provider refusal of the saved key
// and the sentence to show. A wrapped error is found through errors.As.
func RefusedMessage(err error) (string, bool) {
var re *RefusedError
if errors.As(err, &re) {
return re.Message, true
}
return "", false
}

// UnreachableError is a provider that could not be reached (a transport error
// or a timeout): not a refusal of the key, and not a raw network error for a
// person to read. Error() is the transport text for logs; Words() is the plain
// sentence.
type UnreachableError struct {
Provider string
Detail string
}

func (e *UnreachableError) Error() string { return e.Detail }

// ProviderName is the display name of the provider that could not be reached.
func (e *UnreachableError) ProviderName() string { return e.Provider }

// Words is what a person reads when the saved key could not be tested.
func (e *UnreachableError) Words() string { return UnreachableWords(e.Provider) }

// UnreachableWords is the sentence for provider (a display name).
func UnreachableWords(provider string) string {
return fmt.Sprintf("The key is saved. %s could not be reached to test it; press Test connection later.", provider)
}

// PressedUnreachableWords is the sentence for a pressed Test connection that
// could not reach provider: nothing was saved by the press, so it does not say
// the key is saved.
func PressedUnreachableWords(provider string) string {
return fmt.Sprintf("%s could not be reached; try Test connection again later.", provider)
}

// UnreachableMessage reports whether err is an unreachable provider and the
// sentence to show. A wrapped error is found through errors.As.
func UnreachableMessage(err error) (string, bool) {
var ue *UnreachableError
if errors.As(err, &ue) {
return ue.Words(), true
}
return "", false
}
Loading
Loading