2026-09-24 01:57:25 +00:00
package llm
import (
"context"
"encoding/json"
2026-09-24 17:07:01 +00:00
"errors"
2026-09-24 01:57:25 +00:00
"fmt"
"strings"
2026-09-24 17:07:01 +00:00
"sync"
2026-09-24 01:57:25 +00:00
"time"
2026-10-11 01:40:38 +00:00
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/outcomes"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/responseschema"
"gitea.maximumdirect.net/eric/audita/v3/internal/framework/structuredoutput"
"gitea.maximumdirect.net/eric/audita/v3/internal/prompts"
2026-09-24 01:57:25 +00:00
pk "gitea.maximumdirect.net/eric/promptkit"
)
// CallHooks receive one logical start and finish record and the execution
// events for this call. Call records can contain transcript content.
type CallHooks struct {
2026-09-24 02:23:57 +00:00
OnCall func ( structuredoutput . CallRecord )
OnEvent pk . ExecutionObserver
OnEventWithCall func ( uint64 , structuredoutput . DiagnosticContext , pk . ExecutionEvent )
2026-09-24 01:57:25 +00:00
}
type RoleClient struct {
runtime * Runtime
role string
hooks CallHooks
}
var _ structuredoutput . Client = ( * RoleClient ) ( nil )
func ( r * Runtime ) NewRoleClient ( role string , hooks CallHooks ) ( * RoleClient , error ) {
if _ , ok := r . Profiles [ role ] ; ! ok {
return nil , fmt . Errorf ( "unknown LLM role %q" , role )
}
return & RoleClient { runtime : r , role : role , hooks : hooks } , nil
}
func ( c * RoleClient ) CompleteStructured ( parent context . Context , req structuredoutput . Request , out any ) ( response structuredoutput . Response , err error ) {
ctx , cancel := context . WithTimeout ( parent , c . runtime . Config . LLM . OperationTimeout )
defer cancel ( )
2026-09-24 17:07:01 +00:00
var terminalMu sync . Mutex
var terminal pk . ExecutionEvent
terminalAvailable := false
defer func ( ) {
terminalMu . Lock ( )
if terminalAvailable {
response . Execution = outcomes . ExecutionCounts {
Available : true ,
ProviderAttempts : int ( terminal . Attempts ) ,
TransientRetries : int ( terminal . TransientRetries ) ,
StructuralRepairs : int ( terminal . RepairCalls ) ,
RetriesExhausted : terminal . RetriesExhausted ,
}
if terminal . StatusCode != 0 {
status := terminal . StatusCode
response . Execution . HTTPStatus = & status
}
}
terminalMu . Unlock ( )
} ( )
2026-09-24 02:23:57 +00:00
record := structuredoutput . CallRecord { CallID : c . runtime . nextCallID . Add ( 1 ) , Context : copyContext ( req . Context ) , Started : time . Now ( ) . UTC ( ) }
2026-09-24 01:57:25 +00:00
record . Context . Role = c . role
2026-09-24 02:31:37 +00:00
profile := c . runtime . Profiles [ c . role ]
record . Prompt = structuredoutput . PromptMetadata { PromptID : req . PromptID , PromptVersion : req . PromptVersion , ProfileID : profile . ProfileID , BackendID : profile . EffectiveModelParams . BackendID , Model : profile . EffectiveModelParams . Model }
2026-09-24 01:57:25 +00:00
if c . hooks . OnCall != nil {
c . hooks . OnCall ( record )
}
defer func ( ) {
record . Finished = time . Now ( ) . UTC ( )
if err != nil {
2026-09-24 17:07:01 +00:00
var failure * structuredoutput . Failure
if errors . As ( err , & failure ) && failure . Reason == outcomes . ReasonInvalidOutputExhausted {
2026-09-24 01:57:25 +00:00
record . Failure = structuredoutput . FailureMalformedOutput
} else {
record . Failure = structuredoutput . FailureRuntime
}
}
if c . hooks . OnCall != nil {
c . hooks . OnCall ( record )
}
} ( )
2026-09-24 17:07:01 +00:00
if parent . Err ( ) != nil {
return response , classifiedFailure ( parent , ctx , parent . Err ( ) , nil )
}
2026-09-24 01:57:25 +00:00
if err = validateOutputTarget ( out ) ; err != nil {
2026-09-24 17:07:01 +00:00
return response , & structuredoutput . Failure { Class : structuredoutput . FailureFatal , Reason : outcomes . ReasonSetupFailure , Message : "invalid structured output target" , Cause : err }
2026-09-24 01:57:25 +00:00
}
schemaPath , ok := responseschema . AssetPath ( req . Schema )
if ! ok {
2026-09-24 17:07:01 +00:00
return response , & structuredoutput . Failure { Class : structuredoutput . FailureFatal , Reason : outcomes . ReasonSetupFailure , Message : "unknown structured response schema" , Cause : fmt . Errorf ( "schema %q" , req . Schema ) }
2026-09-24 01:57:25 +00:00
}
backend := c . runtime . Config . Backends [ profile . EffectiveModelParams . BackendID ]
vars := make ( map [ string ] string , len ( req . Variables ) + 1 )
for key , value := range req . Variables {
vars [ key ] = value
}
2026-09-25 13:26:51 +00:00
for key , value := range prompts . SharedVariables ( ) {
vars [ key ] = value
}
2026-09-24 01:57:25 +00:00
apiKey := ""
if backend . Credential != nil {
apiKey = backend . Credential . Value ( )
}
handle , prepErr := c . runtime . Engine . PrepareExecution ( ctx , pk . RunRequest {
PromptID : req . PromptID , PromptVersion : req . PromptVersion ,
ProfileID : profile . ProfileID , APIKey : apiKey , Vars : vars ,
Validation : & pk . OutputContract { Format : pk . FormatJSON , ValidationMode : pk . ValidationJSONSchema , SchemaPath : schemaPath , RepairAttempts : c . runtime . Config . LLM . RepairAttempts } ,
2026-09-24 02:23:57 +00:00
Observer : func ( event pk . ExecutionEvent ) {
2026-09-24 17:07:01 +00:00
if event . Kind == pk . ExecutionFinished {
terminalMu . Lock ( )
terminal = event
terminalAvailable = true
terminalMu . Unlock ( )
}
2026-09-24 02:23:57 +00:00
if c . hooks . OnEvent != nil {
c . hooks . OnEvent ( event )
}
if c . hooks . OnEventWithCall != nil {
c . hooks . OnEventWithCall ( record . CallID , record . Context , event )
}
} ,
2026-09-24 01:57:25 +00:00
} )
if prepErr != nil {
2026-09-24 17:07:01 +00:00
return response , classifiedFailure ( parent , ctx , prepErr , nil )
2026-09-24 01:57:25 +00:00
}
defer handle . Discard ( )
details := handle . Details ( )
response . Prompt = promptMetadata ( details )
2026-09-24 02:31:37 +00:00
record . Prompt = response . Prompt
2026-09-24 01:57:25 +00:00
response . Model = details . EffectiveModelParams . Model
response . Prepared = preparedSnapshot ( details )
record . Prepared = response . Prepared
selected , exists := c . runtime . Config . Backends [ details . SelectedBackendID ]
endpoint := func ( s string ) string { return strings . TrimRight ( strings . TrimSpace ( s ) , "/" ) }
if ! exists || details . SelectedProfileID != profile . ProfileID || details . SelectedBackendID != profile . EffectiveModelParams . BackendID || endpoint ( details . EffectiveModelParams . Endpoint ) != endpoint ( selected . Endpoint ) || details . EffectiveModelParams . APIKeyEnv != "" {
2026-09-24 17:07:01 +00:00
return response , & structuredoutput . Failure { Class : structuredoutput . FailureFatal , Reason : outcomes . ReasonSetupFailure , Message : "structured model target changed after configuration inspection" , Cause : fmt . Errorf ( "prepared target does not match registered role backend" ) }
2026-09-24 01:57:25 +00:00
}
result , runErr := c . runtime . Engine . RunPrepared ( ctx , handle )
if result != nil {
response . FinalContent = result . RawOutput
record . FinalContent = result . RawOutput
response . Model = result . ModelName
response . PromptTokens = result . Usage . PromptTokens
response . CompletionTokens = result . Usage . CompletionTokens
response . TotalTokens = result . Usage . TotalTokens
}
if runErr != nil {
2026-09-24 17:07:01 +00:00
return response , classifiedFailure ( parent , ctx , runErr , result )
2026-09-24 01:57:25 +00:00
}
if result == nil {
2026-09-24 17:07:01 +00:00
return response , & structuredoutput . Failure { Class : structuredoutput . FailureFatal , Reason : outcomes . ReasonInternalFailure , Message : "structured model call returned no result" }
2026-09-24 01:57:25 +00:00
}
if ! result . Validation . IsValid {
2026-09-24 17:07:01 +00:00
return response , classifiedFailure ( parent , ctx , nil , result )
2026-09-24 01:57:25 +00:00
}
if decodeErr := json . Unmarshal ( [ ] byte ( result . RawOutput ) , out ) ; decodeErr != nil {
2026-09-24 17:07:01 +00:00
var typeError * json . UnmarshalTypeError
if errors . As ( decodeErr , & typeError ) {
return response , & structuredoutput . Failure { Class : structuredoutput . FailureRecoverable , Reason : outcomes . ReasonInvalidOutput , Message : "structured output contains an unusable value" , Cause : decodeErr }
}
return response , & structuredoutput . Failure { Class : structuredoutput . FailureFatal , Reason : outcomes . ReasonInternalFailure , Message : "validated structured output could not be decoded" , Cause : decodeErr }
2026-09-24 01:57:25 +00:00
}
response . Content = json . RawMessage ( result . RawOutput )
response . Provider = "openai-compatible"
return response , nil
}
2026-09-24 17:07:01 +00:00
// classifiedFailure deliberately checks setup defects before broad PromptKit
// validation/generation wrappers. Provider text is kept only in the typed cause.
func classifiedFailure ( parent , local context . Context , cause error , result * pk . RunResult ) * structuredoutput . Failure {
failure := & structuredoutput . Failure { Class : structuredoutput . FailureFatal , Reason : outcomes . ReasonInternalFailure , Message : "structured model call failed" , Cause : cause }
if parent . Err ( ) != nil {
failure . Class = structuredoutput . FailureCanceled
failure . Reason = outcomes . ReasonCanceled
failure . Message = "processing canceled"
failure . Cause = parent . Err ( )
return failure
}
if isSetupFailure ( cause ) {
failure . Reason = outcomes . ReasonSetupFailure
failure . Message = "structured model setup failed"
return failure
}
if errors . Is ( local . Err ( ) , context . DeadlineExceeded ) || errors . Is ( cause , context . DeadlineExceeded ) {
failure . Class = structuredoutput . FailureRecoverable
failure . Reason = outcomes . ReasonOperationTimeout
failure . Message = "model operation timed out"
return failure
}
if errors . Is ( cause , pk . ErrCapacityExceeded ) {
failure . Class = structuredoutput . FailureRecoverable
failure . Reason = outcomes . ReasonCapacityExceeded
failure . Message = "model capacity was unavailable"
return failure
}
if errors . Is ( cause , pk . ErrTransport ) {
failure . Class = structuredoutput . FailureRecoverable
failure . Reason = outcomes . ReasonTransportFailed
failure . Message = "model transport failed"
return failure
}
if errors . Is ( cause , pk . ErrMalformedResponse ) {
failure . Class = structuredoutput . FailureRecoverable
failure . Reason = outcomes . ReasonProviderResponseInvalid
failure . Message = "model provider response was invalid"
return failure
}
var provider * pk . GenerationError
if errors . As ( cause , & provider ) && provider . StatusCode ( ) != 0 {
failure . Class = structuredoutput . FailureRecoverable
failure . Reason = outcomes . ReasonProviderFailed
failure . Message = "model provider failed"
return failure
}
if result != nil && ! result . Validation . IsValid {
failure . Class = structuredoutput . FailureRecoverable
failure . Kind = structuredoutput . FailureMalformedOutput
failure . Reason = outcomes . ReasonInvalidOutputExhausted
failure . Message = "structured output remained invalid"
return failure
}
if errors . Is ( cause , pk . ErrValidation ) {
failure . Reason = outcomes . ReasonSetupFailure
failure . Message = "structured validation contract failed"
}
return failure
}
func isSetupFailure ( err error ) bool {
return errors . Is ( err , pk . ErrInvalidConfig ) || errors . Is ( err , pk . ErrInvalidRequest ) ||
errors . Is ( err , pk . ErrPromptNotFound ) || errors . Is ( err , pk . ErrProfileNotFound ) ||
errors . Is ( err , pk . ErrPromptLoad ) || errors . Is ( err , pk . ErrProfileLoad ) ||
errors . Is ( err , pk . ErrArtifactLoad ) || errors . Is ( err , pk . ErrPromptRender )
}
2026-09-24 01:57:25 +00:00
func promptMetadata ( details pk . PreparedRun ) structuredoutput . PromptMetadata {
return structuredoutput . PromptMetadata {
PromptID : details . PromptID , PromptVersion : details . PromptVersion ,
DefinitionHash : details . PromptHash , RenderedHash : details . RenderedPromptHash ,
ProfileID : details . SelectedProfileID , BackendID : details . SelectedBackendID ,
Model : details . EffectiveModelParams . Model ,
}
}
func preparedSnapshot ( details pk . PreparedRun ) * structuredoutput . PreparedSnapshot {
messages := make ( [ ] structuredoutput . Message , len ( details . Messages ) )
for i , message := range details . Messages {
messages [ i ] = structuredoutput . Message { Role : string ( message . Role ) , Content : message . Content }
}
return & structuredoutput . PreparedSnapshot { Messages : messages , Prompt : promptMetadata ( details ) }
}